From f1bd8fb3ee0d1bf2eedbb3b4292d01939229be88 Mon Sep 17 00:00:00 2001 From: telli Date: Thu, 8 Oct 2026 15:26:22 -0700 Subject: [PATCH 1/4] feat: add .NET intelligence, verification, MCP hosting, and native releases --- .github/scripts/Publish-Binaries.ps1 | 40 ++ .github/scripts/Test-Binary.ps1 | 65 +++ .github/scripts/Test-Packages.ps1 | 28 +- .github/workflows/release.yml | 110 ++++- Directory.Packages.props | 3 + README.md | 20 +- SharpClawCode.Packages.slnf | 1 + SharpClawCode.sln | 15 + docs/architecture.md | 2 + docs/binary-distribution.md | 37 ++ docs/dotnet-intelligence.md | 40 ++ docs/mcp-server.md | 37 ++ docs/runtime.md | 2 + ...elligence-verification-mcp-distribution.md | 434 ++++++++++++++++++ docs/testing.md | 2 + docs/toc.yml | 11 + docs/tools.md | 4 + docs/verification.md | 40 ++ .../CliServiceCollectionExtensions.cs | 4 + src/SharpClaw.Code.Cli/Program.cs | 22 +- .../Rendering/TextOutputRenderer.cs | 1 + .../SharpClaw.Code.Cli.csproj | 1 + .../Handlers/McpCommandHandler.cs | 33 +- .../Handlers/VerifyCommandHandler.cs | 72 +++ .../Handlers/VersionCommandHandler.cs | 4 +- .../PromptInvocationService.cs | 3 +- src/SharpClaw.Code.Commands/Repl/ReplHost.cs | 2 +- .../SharpClaw.Code.Mcp.Server.csproj | 9 + .../SharpClawMcpHttpServer.cs | 92 ++++ .../SharpClawMcpServer.cs | 47 ++ .../SharpClawMcpToolBridge.cs | 122 +++++ .../Abstractions/ISharpClawMcpServer.cs | 10 + .../Models/SharpClawMcpServerOptions.cs | 38 ++ ...IDotNetWorkspaceEvaluationAuthorization.cs | 10 + .../IDotNetWorkspaceSemanticService.cs | 21 + .../IDotNetWorkspaceTargetResolver.cs | 10 + .../MemoryServiceCollectionExtensions.cs | 2 + .../Services/DotNetSemanticWorkspaceCache.cs | 279 +++++++++++ .../DotNetWorkspaceSemanticQueries.cs | 91 ++++ .../Services/DotNetWorkspaceSemanticRename.cs | 52 +++ .../DotNetWorkspaceSemanticService.cs | 151 ++++++ .../Services/DotNetWorkspaceTargetResolver.cs | 49 ++ .../SharpClaw.Code.Memory.csproj | 2 + .../Models/PermissionRequestSourceKind.cs | 3 + .../Commands/TurnExecutionResult.cs | 4 +- .../Events/RuntimeEvent.cs | 4 + .../Events/VerificationEvents.cs | 10 + .../Models/CSharpRenameModels.cs | 8 + .../Models/CSharpToolModels.cs | 25 + .../Models/ConversationTurn.cs | 4 +- .../Models/DotNetSemanticModels.cs | 122 +++++ .../Models/OpenCodeParityModels.cs | 4 +- .../Models/SharpClawWorkflowMetadataKeys.cs | 8 + .../Models/VerificationModels.cs | 80 ++++ .../Models/VerificationPolicyModels.cs | 10 + .../Serialization/ProtocolJsonContext.cs | 41 ++ .../RuntimeServiceCollectionExtensions.cs | 12 + .../Configuration/SharpClawConfigService.cs | 11 +- .../WorkspaceDiagnosticsService.cs | 74 +-- .../CheckpointMutationCoordinator.cs | 3 +- .../Mutations/MutationWorkspaceApplier.cs | 4 +- .../Orchestration/ConversationRuntime.cs | 58 ++- .../Turns/DefaultTurnRunner.cs | 15 +- .../Turns/TurnMutationAccumulator.cs | 9 +- .../Turns/TurnMutationJournal.cs | 13 + .../Turns/TurnRunResult.cs | 8 +- .../Verification/AffectedProjectResolver.cs | 30 ++ .../Verification/DotNetDiagnosticParser.cs | 36 ++ .../Verification/IAffectedProjectResolver.cs | 8 + .../Verification/IDotNetDiagnosticParser.cs | 8 + .../Verification/ITestResultParser.cs | 8 + .../IVerificationDiagnosticsCache.cs | 10 + .../IVerificationLoopCoordinator.cs | 14 + .../IVerificationPolicyResolver.cs | 8 + .../Verification/IVerificationService.cs | 8 + .../Verification/Tools/VerifyWorkspaceTool.cs | 32 ++ .../Verification/TrxTestResultParser.cs | 35 ++ .../VerificationDiagnosticsCache.cs | 17 + .../VerificationFailureContextFormatter.cs | 16 + .../VerificationLoopCoordinator.cs | 98 ++++ .../VerificationPolicyResolver.cs | 21 + .../Verification/VerificationService.cs | 181 ++++++++ .../IToolPermissionDeniedResultFactory.cs | 9 + .../BuiltIn/CSharpDiagnosticsTool.cs | 28 ++ .../BuiltIn/CSharpFindReferencesTool.cs | 28 ++ .../BuiltIn/CSharpRenameSymbolTool.cs | 85 ++++ .../BuiltIn/CSharpSymbolResolveTool.cs | 28 ++ .../BuiltIn/CSharpTypeHierarchyTool.cs | 28 ++ .../BuiltIn/DotNetSolutionInspectTool.cs | 28 ++ .../BuiltIn/SharpClawToolBase.cs | 14 + .../DotNetEvaluationAuthorization.cs | 29 ++ .../Execution/ToolExecutor.cs | 2 +- .../ToolsServiceCollectionExtensions.cs | 7 + src/SharpClaw.Code/SharpClaw.Code.csproj | 1 + .../DotNetSemantic/Consumer/Consumer.csproj | 9 + .../Consumer/InvoiceConsumer.cs | 10 + .../Fixture.Tests/Fixture.Tests.csproj | 15 + .../Fixture.Tests/InvoiceTests.cs | 7 + tests/Fixtures/DotNetSemantic/Fixture.slnx | 4 + .../DotNetSemantic/Library/Invoice.cs | 28 ++ .../DotNetSemantic/Library/Library.csproj | 9 + .../Fixtures/DotNetFixtureWorkspace.cs | 67 +++ .../Fixtures/FaultingFileSystem.cs | 29 ++ .../Mcp/InboundMcpHttpTests.cs | 134 ++++++ .../Mcp/InboundMcpStdioTests.cs | 153 ++++++ .../Memory/CSharpRenameTests.cs | 206 +++++++++ .../Memory/CSharpSemanticToolTests.cs | 88 ++++ .../Memory/DotNetWorkspaceSemanticTests.cs | 150 ++++++ .../Memory/SemanticImportCacheTests.cs | 43 ++ .../SharpClaw.Code.IntegrationTests.csproj | 7 + .../AutomaticVerificationTests.cs | 198 ++++++++ .../RealAutomaticVerificationTests.cs | 61 +++ .../Verification/VerificationServiceTests.cs | 101 ++++ .../Verification/VerificationSurfaceTests.cs | 102 ++++ .../ParityScenarioIds.cs | 10 + .../ParityScenarioTests.cs | 2 +- .../SemanticParityTests.cs | 46 ++ .../SharpClaw.Code.ParityHarness.csproj | 7 + .../VerificationParityTests.cs | 44 ++ .../DotNetWorkspaceTargetResolverTests.cs | 89 ++++ .../Runtime/SharpClawConfigServiceTests.cs | 3 + .../Verification/VerificationParserTests.cs | 46 ++ .../Verification/VerificationPolicyTests.cs | 35 ++ 123 files changed, 4954 insertions(+), 114 deletions(-) create mode 100644 .github/scripts/Publish-Binaries.ps1 create mode 100644 .github/scripts/Test-Binary.ps1 create mode 100644 docs/binary-distribution.md create mode 100644 docs/dotnet-intelligence.md create mode 100644 docs/mcp-server.md create mode 100644 docs/superpowers/plans/2026-10-06-dotnet-intelligence-verification-mcp-distribution.md create mode 100644 docs/verification.md create mode 100644 src/SharpClaw.Code.Commands/Handlers/VerifyCommandHandler.cs create mode 100644 src/SharpClaw.Code.Mcp.Server/SharpClaw.Code.Mcp.Server.csproj create mode 100644 src/SharpClaw.Code.Mcp.Server/SharpClawMcpHttpServer.cs create mode 100644 src/SharpClaw.Code.Mcp.Server/SharpClawMcpServer.cs create mode 100644 src/SharpClaw.Code.Mcp.Server/SharpClawMcpToolBridge.cs create mode 100644 src/SharpClaw.Code.Mcp/Abstractions/ISharpClawMcpServer.cs create mode 100644 src/SharpClaw.Code.Mcp/Models/SharpClawMcpServerOptions.cs create mode 100644 src/SharpClaw.Code.Memory/Abstractions/IDotNetWorkspaceEvaluationAuthorization.cs create mode 100644 src/SharpClaw.Code.Memory/Abstractions/IDotNetWorkspaceSemanticService.cs create mode 100644 src/SharpClaw.Code.Memory/Abstractions/IDotNetWorkspaceTargetResolver.cs create mode 100644 src/SharpClaw.Code.Memory/Services/DotNetSemanticWorkspaceCache.cs create mode 100644 src/SharpClaw.Code.Memory/Services/DotNetWorkspaceSemanticQueries.cs create mode 100644 src/SharpClaw.Code.Memory/Services/DotNetWorkspaceSemanticRename.cs create mode 100644 src/SharpClaw.Code.Memory/Services/DotNetWorkspaceSemanticService.cs create mode 100644 src/SharpClaw.Code.Memory/Services/DotNetWorkspaceTargetResolver.cs create mode 100644 src/SharpClaw.Code.Protocol/Events/VerificationEvents.cs create mode 100644 src/SharpClaw.Code.Protocol/Models/CSharpRenameModels.cs create mode 100644 src/SharpClaw.Code.Protocol/Models/CSharpToolModels.cs create mode 100644 src/SharpClaw.Code.Protocol/Models/DotNetSemanticModels.cs create mode 100644 src/SharpClaw.Code.Protocol/Models/VerificationModels.cs create mode 100644 src/SharpClaw.Code.Protocol/Models/VerificationPolicyModels.cs create mode 100644 src/SharpClaw.Code.Runtime/Turns/TurnMutationJournal.cs create mode 100644 src/SharpClaw.Code.Runtime/Verification/AffectedProjectResolver.cs create mode 100644 src/SharpClaw.Code.Runtime/Verification/DotNetDiagnosticParser.cs create mode 100644 src/SharpClaw.Code.Runtime/Verification/IAffectedProjectResolver.cs create mode 100644 src/SharpClaw.Code.Runtime/Verification/IDotNetDiagnosticParser.cs create mode 100644 src/SharpClaw.Code.Runtime/Verification/ITestResultParser.cs create mode 100644 src/SharpClaw.Code.Runtime/Verification/IVerificationDiagnosticsCache.cs create mode 100644 src/SharpClaw.Code.Runtime/Verification/IVerificationLoopCoordinator.cs create mode 100644 src/SharpClaw.Code.Runtime/Verification/IVerificationPolicyResolver.cs create mode 100644 src/SharpClaw.Code.Runtime/Verification/IVerificationService.cs create mode 100644 src/SharpClaw.Code.Runtime/Verification/Tools/VerifyWorkspaceTool.cs create mode 100644 src/SharpClaw.Code.Runtime/Verification/TrxTestResultParser.cs create mode 100644 src/SharpClaw.Code.Runtime/Verification/VerificationDiagnosticsCache.cs create mode 100644 src/SharpClaw.Code.Runtime/Verification/VerificationFailureContextFormatter.cs create mode 100644 src/SharpClaw.Code.Runtime/Verification/VerificationLoopCoordinator.cs create mode 100644 src/SharpClaw.Code.Runtime/Verification/VerificationPolicyResolver.cs create mode 100644 src/SharpClaw.Code.Runtime/Verification/VerificationService.cs create mode 100644 src/SharpClaw.Code.Tools/Abstractions/IToolPermissionDeniedResultFactory.cs create mode 100644 src/SharpClaw.Code.Tools/BuiltIn/CSharpDiagnosticsTool.cs create mode 100644 src/SharpClaw.Code.Tools/BuiltIn/CSharpFindReferencesTool.cs create mode 100644 src/SharpClaw.Code.Tools/BuiltIn/CSharpRenameSymbolTool.cs create mode 100644 src/SharpClaw.Code.Tools/BuiltIn/CSharpSymbolResolveTool.cs create mode 100644 src/SharpClaw.Code.Tools/BuiltIn/CSharpTypeHierarchyTool.cs create mode 100644 src/SharpClaw.Code.Tools/BuiltIn/DotNetSolutionInspectTool.cs create mode 100644 src/SharpClaw.Code.Tools/Execution/DotNetEvaluationAuthorization.cs create mode 100644 tests/Fixtures/DotNetSemantic/Consumer/Consumer.csproj create mode 100644 tests/Fixtures/DotNetSemantic/Consumer/InvoiceConsumer.cs create mode 100644 tests/Fixtures/DotNetSemantic/Fixture.Tests/Fixture.Tests.csproj create mode 100644 tests/Fixtures/DotNetSemantic/Fixture.Tests/InvoiceTests.cs create mode 100644 tests/Fixtures/DotNetSemantic/Fixture.slnx create mode 100644 tests/Fixtures/DotNetSemantic/Library/Invoice.cs create mode 100644 tests/Fixtures/DotNetSemantic/Library/Library.csproj create mode 100644 tests/SharpClaw.Code.IntegrationTests/Fixtures/DotNetFixtureWorkspace.cs create mode 100644 tests/SharpClaw.Code.IntegrationTests/Fixtures/FaultingFileSystem.cs create mode 100644 tests/SharpClaw.Code.IntegrationTests/Mcp/InboundMcpHttpTests.cs create mode 100644 tests/SharpClaw.Code.IntegrationTests/Mcp/InboundMcpStdioTests.cs create mode 100644 tests/SharpClaw.Code.IntegrationTests/Memory/CSharpRenameTests.cs create mode 100644 tests/SharpClaw.Code.IntegrationTests/Memory/CSharpSemanticToolTests.cs create mode 100644 tests/SharpClaw.Code.IntegrationTests/Memory/DotNetWorkspaceSemanticTests.cs create mode 100644 tests/SharpClaw.Code.IntegrationTests/Memory/SemanticImportCacheTests.cs create mode 100644 tests/SharpClaw.Code.IntegrationTests/Verification/AutomaticVerificationTests.cs create mode 100644 tests/SharpClaw.Code.IntegrationTests/Verification/RealAutomaticVerificationTests.cs create mode 100644 tests/SharpClaw.Code.IntegrationTests/Verification/VerificationServiceTests.cs create mode 100644 tests/SharpClaw.Code.IntegrationTests/Verification/VerificationSurfaceTests.cs create mode 100644 tests/SharpClaw.Code.ParityHarness/SemanticParityTests.cs create mode 100644 tests/SharpClaw.Code.ParityHarness/VerificationParityTests.cs create mode 100644 tests/SharpClaw.Code.UnitTests/MemorySkillsGit/DotNetWorkspaceTargetResolverTests.cs create mode 100644 tests/SharpClaw.Code.UnitTests/Verification/VerificationParserTests.cs create mode 100644 tests/SharpClaw.Code.UnitTests/Verification/VerificationPolicyTests.cs diff --git a/.github/scripts/Publish-Binaries.ps1 b/.github/scripts/Publish-Binaries.ps1 new file mode 100644 index 0000000..0b548c7 --- /dev/null +++ b/.github/scripts/Publish-Binaries.ps1 @@ -0,0 +1,40 @@ +param( + [Parameter(Mandatory)][ValidateSet('win-x64','win-arm64','linux-x64','linux-arm64','osx-x64','osx-arm64')][string]$Runtime, + [Parameter(Mandatory)][ValidatePattern('^\d+\.\d+\.\d+([-.][0-9A-Za-z.-]+)?$')][string]$Version, + [string]$OutputDirectory = 'artifacts/binaries' +) +$ErrorActionPreference = 'Stop' +$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path +$outputRoot = [System.IO.Path]::GetFullPath($OutputDirectory) +$scratch = Join-Path ([System.IO.Path]::GetTempPath()) ('sharpclaw-publish-' + [Guid]::NewGuid().ToString('N')) +try { + $publish = Join-Path $scratch 'publish' + $stage = Join-Path $scratch 'stage' + New-Item -ItemType Directory -Path $stage,$outputRoot -Force | Out-Null + & dotnet publish (Join-Path $repositoryRoot 'src/SharpClaw.Code.Cli/SharpClaw.Code.Cli.csproj') --configuration Release --runtime $Runtime --self-contained true --output $publish --disable-build-servers ` + -p:PackAsTool=false -p:PublishSingleFile=true -p:PublishTrimmed=false -p:PublishAot=false ` + -p:IncludeNativeLibrariesForSelfExtract=true -p:IncludeAllContentForSelfExtract=true ` + "-p:Version=$Version" "-p:PackageVersion=$Version" "-p:InformationalVersion=$Version" -p:IncludeSourceRevisionInInformationalVersion=false + if ($LASTEXITCODE -ne 0) { throw "Binary publish failed: $Runtime" } + $sourceExecutable = if ($Runtime.StartsWith('win-')) { 'SharpClaw.Code.Cli.exe' } else { 'SharpClaw.Code.Cli' } + $executable = if ($Runtime.StartsWith('win-')) { 'sharpclaw.exe' } else { 'sharpclaw' } + Copy-Item (Join-Path $publish $sourceExecutable) (Join-Path $stage $executable) + Copy-Item (Join-Path $repositoryRoot 'LICENSE') $stage + Copy-Item (Join-Path $repositoryRoot 'README.md') $stage + $stem = "sharpclaw-$Version-$Runtime" + if ($Runtime.StartsWith('win-')) { + $archive = Join-Path $outputRoot "$stem.zip" + Compress-Archive -Path (Join-Path $stage '*') -DestinationPath $archive -Force + } else { + & chmod +x (Join-Path $stage $executable) + if ($LASTEXITCODE -ne 0) { throw 'Could not preserve executable permission.' } + $archive = Join-Path $outputRoot "$stem.tar.gz" + & tar -czf $archive -C $stage $executable LICENSE README.md + if ($LASTEXITCODE -ne 0) { throw 'Could not create binary archive.' } + } + $checksum = (Get-FileHash $archive -Algorithm SHA256).Hash.ToLowerInvariant() + [System.IO.File]::WriteAllText("$archive.sha256", "$checksum $([System.IO.Path]::GetFileName($archive))`n") + Write-Host "Created $archive" +} finally { + if (Test-Path $scratch) { Remove-Item $scratch -Recurse -Force } +} diff --git a/.github/scripts/Test-Binary.ps1 b/.github/scripts/Test-Binary.ps1 new file mode 100644 index 0000000..2e1383b --- /dev/null +++ b/.github/scripts/Test-Binary.ps1 @@ -0,0 +1,65 @@ +param( + [Parameter(Mandatory)][string]$Archive, + [Parameter(Mandatory)][ValidateSet('win-x64','win-arm64','linux-x64','linux-arm64','osx-x64','osx-arm64')][string]$Runtime, + [Parameter(Mandatory)][string]$Version +) +$ErrorActionPreference = 'Stop' +$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path +$architecture = [System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString().ToLowerInvariant() +$platform = if ($IsWindows) { 'win' } elseif ($IsMacOS) { 'osx' } else { 'linux' } +if ("$platform-$architecture" -ne $Runtime) { throw "Smoke must execute on a matching runner: expected $Runtime, got $platform-$architecture." } +$scratch = Join-Path ([System.IO.Path]::GetTempPath()) ('sharpclaw-binary-smoke-' + [Guid]::NewGuid().ToString('N')) +$previousBinary = $env:SHARPCLAW_TEST_BINARY +try { + $extract = Join-Path $scratch 'extract' + $workspace = Join-Path $scratch 'workspace' + New-Item -ItemType Directory -Path $extract,$workspace -Force | Out-Null + $archivePath = (Resolve-Path $Archive).Path + $checksumPath = "$archivePath.sha256" + if (!(Test-Path $checksumPath)) { throw 'Binary archive checksum is missing.' } + $expected = (Get-Content $checksumPath -Raw).Split(' ')[0] + if ((Get-FileHash $archivePath -Algorithm SHA256).Hash.ToLowerInvariant() -ne $expected) { throw 'Binary archive checksum mismatch.' } + if ($IsWindows) { Expand-Archive $archivePath $extract } + else { + & tar -xzf $archivePath -C $extract + if ($LASTEXITCODE -ne 0) { throw 'Archive extraction failed.' } + } + $executableName = if ($IsWindows) { 'sharpclaw.exe' } else { 'sharpclaw' } + $binary = Join-Path $extract $executableName + $files = @(Get-ChildItem $extract -File | Select-Object -ExpandProperty Name | Sort-Object) + if (($files -join ',') -ne ((@($executableName,'LICENSE','README.md') | Sort-Object) -join ',')) { throw 'Archive must contain exactly executable, LICENSE, and README.md.' } + # An empty PATH and isolated runtime roots prove the native bundle starts without resolving an ambient dotnet host. + $start = [System.Diagnostics.ProcessStartInfo]::new($binary) + $start.UseShellExecute = $false + $start.RedirectStandardOutput = $true + $start.RedirectStandardError = $true + $start.WorkingDirectory = $workspace + $start.ArgumentList.Add('--output-format'); $start.ArgumentList.Add('json'); $start.ArgumentList.Add('version') + $start.Environment['PATH'] = '' + foreach ($key in @('DOTNET_ROOT','DOTNET_ROOT_X64','DOTNET_ROOT_ARM64')) { $start.Environment[$key] = (Join-Path $scratch 'no-dotnet') } + $start.Environment['DOTNET_MULTILEVEL_LOOKUP'] = '0' + $process = [System.Diagnostics.Process]::Start($start) + $stdout = $process.StandardOutput.ReadToEndAsync(); $stderr = $process.StandardError.ReadToEndAsync() + if (!$process.WaitForExit(30000)) { $process.Kill($true); throw 'Self-contained startup timed out.' } + $text = $stdout.GetAwaiter().GetResult(); $errorText = $stderr.GetAwaiter().GetResult() + if ($process.ExitCode -ne 0) { throw "No-runtime version failed: $errorText" } + $process.Dispose() + $versionResult = $text | ConvertFrom-Json + if ($versionResult.succeeded -ne $true -or $versionResult.data.InformationalVersion -ne $Version) { throw "Binary did not report release identity $Version`: $text" } + [System.IO.File]::WriteAllText((Join-Path $workspace 'note.txt'), 'offline binary SQLite index smoke') + & $binary --cwd $workspace --output-format json verify --permission-mode dangerFullAccess + if ($LASTEXITCODE -ne 0) { throw 'Non-.NET verification skip failed.' } + & $binary --cwd $workspace --output-format json index refresh + if ($LASTEXITCODE -ne 0) { throw 'SQLite index refresh failed.' } + & $binary --cwd $workspace --output-format json index stats + if ($LASTEXITCODE -ne 0) { throw 'SQLite index stats failed.' } + # Real SDK client tests launch the relocated native binary, exercise both default and elevated calls, + # and prove bundled SQLite, Roslyn BuildHost, semantic rename/undo, and actual build/TRX test results. + $env:SHARPCLAW_TEST_BINARY = $binary + & dotnet test (Join-Path $repositoryRoot 'tests/SharpClaw.Code.IntegrationTests/SharpClaw.Code.IntegrationTests.csproj') --configuration Release --filter 'FullyQualifiedName~InboundMcpStdioTests' --disable-build-servers + if ($LASTEXITCODE -ne 0) { throw 'Relocated binary MCP/semantic/build/test smoke failed.' } + Write-Host "Binary smoke passed: $Runtime $Version" +} finally { + $env:SHARPCLAW_TEST_BINARY = $previousBinary + if (Test-Path $scratch) { Remove-Item $scratch -Recurse -Force } +} diff --git a/.github/scripts/Test-Packages.ps1 b/.github/scripts/Test-Packages.ps1 index 0162891..af13b71 100644 --- a/.github/scripts/Test-Packages.ps1 +++ b/.github/scripts/Test-Packages.ps1 @@ -9,6 +9,7 @@ $scratchRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("sharpclaw-package-s $packageOutput = Join-Path $scratchRoot "packages" $toolPath = Join-Path $scratchRoot "tool" $consumerPath = Join-Path $scratchRoot "consumer" +$previousNugetPackages = $env:NUGET_PACKAGES $nugetConfigPath = Join-Path $scratchRoot "NuGet.Config" try { @@ -18,7 +19,7 @@ try { $packages = @(Get-ChildItem $packageOutput -Filter "*.nupkg" | Where-Object { $_.Name -notlike "*.symbols.nupkg" }) $unexpected = @($packages | Where-Object { $_.BaseName -notlike "SharpClaw.Code*" }) - if ($packages.Count -ne 21) { throw "Expected 21 production packages, found $($packages.Count)." } + if ($packages.Count -ne 22) { throw "Expected 22 production packages, found $($packages.Count)." } if ($unexpected.Count -gt 0) { throw "Unexpected packages: $($unexpected.Name -join ', ')." } & dotnet new console --framework net10.0 --output $consumerPath --no-restore @@ -34,18 +35,41 @@ try { & dotnet nuget add source "https://api.nuget.org/v3/index.json" --name nuget.org --configfile $nugetConfigPath if ($LASTEXITCODE -ne 0) { throw "Could not configure the NuGet.org package source." } + # Reused preview versions must never resolve an older global-cache or public-feed SharpClaw package. + $env:NUGET_PACKAGES = Join-Path $scratchRoot "consumer-package-cache" + [xml]$config = Get-Content $nugetConfigPath -Raw + $mapping = $config.CreateElement("packageSourceMapping") + foreach ($entry in @(@("sharpclaw-local", "SharpClaw.Code*"), @("nuget.org", "*"))) { + $source = $config.CreateElement("packageSource"); $source.SetAttribute("key", $entry[0]) + $pattern = $config.CreateElement("package"); $pattern.SetAttribute("pattern", $entry[1]) + $source.AppendChild($pattern) | Out-Null; $mapping.AppendChild($source) | Out-Null + } + $config.configuration.AppendChild($mapping) | Out-Null + $config.Save($nugetConfigPath) & dotnet restore (Join-Path $consumerPath "consumer.csproj") --configfile $nugetConfigPath if ($LASTEXITCODE -ne 0) { throw "Could not restore the aggregate SDK package." } + [System.IO.File]::WriteAllText((Join-Path $consumerPath "Program.cs"), @' +using Microsoft.Extensions.DependencyInjection; +using SharpClaw.Code.Runtime; +using SharpClaw.Code.Mcp.Server; +using SharpClaw.Code.Mcp.Abstractions; +var services = new ServiceCollection(); +services.AddSharpClawRuntime(); +services.AddSharpClawMcpServer(); +using var provider = services.BuildServiceProvider(); +Console.WriteLine(provider.GetRequiredService().GetType().Name); +'@) & dotnet build (Join-Path $consumerPath "consumer.csproj") --configuration $Configuration --no-restore if ($LASTEXITCODE -ne 0) { throw "The aggregate SDK package failed to build in a clean consumer." } - & dotnet tool install --tool-path $toolPath SharpClaw.Code.Cli --version $PackageVersion --add-source $packageOutput --ignore-failed-sources + & dotnet tool install --tool-path $toolPath SharpClaw.Code.Cli --version $PackageVersion --configfile $nugetConfigPath if ($LASTEXITCODE -ne 0) { throw "The CLI tool package failed to install." } $toolExecutable = if ($IsWindows) { Join-Path $toolPath "sharpclaw.exe" } else { Join-Path $toolPath "sharpclaw" } & $toolExecutable version if ($LASTEXITCODE -ne 0) { throw "The installed CLI tool failed its version smoke test." } } finally { + $env:NUGET_PACKAGES = $previousNugetPackages if (Test-Path $scratchRoot) { Remove-Item $scratchRoot -Recurse -Force } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cdb2766..be8c825 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,12 +5,13 @@ on: tags: ['v*'] permissions: - contents: write + contents: read jobs: - publish: + validate: runs-on: ubuntu-latest - environment: nuget + outputs: + version: ${{ steps.version.outputs.value }} steps: - uses: actions/checkout@v7 - name: Validate semantic version tag @@ -22,8 +23,7 @@ jobs: $version = $env:REF_NAME -replace '^v', '' if ($version -notmatch '^\d+\.\d+\.\d+([-.][0-9A-Za-z.-]+)?$') { throw "Tag is not a supported semantic version." } "value=$version" >> $env:GITHUB_OUTPUT - - name: Setup .NET - uses: actions/setup-dotnet@v6 + - uses: actions/setup-dotnet@v6 with: dotnet-version: '10.0.x' - name: Restore @@ -32,17 +32,109 @@ jobs: shell: pwsh run: ./.github/scripts/Test-VulnerablePackages.ps1 -Target SharpClawCode.sln - name: Build - run: dotnet build SharpClawCode.sln --no-restore --configuration Release --warnaserror + run: dotnet build SharpClawCode.sln --no-restore --configuration Release --warnaserror -p:Version=${{ steps.version.outputs.value }} - name: Test run: dotnet test SharpClawCode.sln --no-build --configuration Release + - name: Agent scenario gates + run: | + dotnet run --project src/SharpClaw.Code.Cli/SharpClaw.Code.Cli.csproj --no-build --configuration Release -- test run + dotnet run --project src/SharpClaw.Code.Cli/SharpClaw.Code.Cli.csproj --no-build --configuration Release -- test gates + - name: Documentation + run: | + dotnet tool restore + dotnet docfx docs/docfx.json --warningsAsErrors - name: Pack production artifacts - run: dotnet pack SharpClawCode.Packages.slnf --no-build --configuration Release --output ./nupkg -p:PackageVersion=${{ steps.version.outputs.value }} + run: dotnet pack SharpClawCode.Packages.slnf --no-build --configuration Release --output ./nupkg -p:Version=${{ steps.version.outputs.value }} -p:PackageVersion=${{ steps.version.outputs.value }} - name: Verify package installability shell: pwsh run: ./.github/scripts/Test-Packages.ps1 -PackageVersion ${{ steps.version.outputs.value }} + - uses: actions/upload-artifact@v7 + with: + name: release-packages + path: nupkg/* + if-no-files-found: error + + binaries: + needs: validate + strategy: + fail-fast: false + matrix: + include: + - rid: win-x64 + runner: windows-2025 + - rid: win-arm64 + runner: windows-11-arm + - rid: linux-x64 + runner: ubuntu-24.04 + - rid: linux-arm64 + runner: ubuntu-24.04-arm + - rid: osx-x64 + runner: macos-15-intel + - rid: osx-arm64 + runner: macos-15 + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-dotnet@v6 + with: + dotnet-version: '10.0.x' + - name: Publish self-contained binary + shell: pwsh + run: ./.github/scripts/Publish-Binaries.ps1 -Runtime ${{ matrix.rid }} -Version ${{ needs.validate.outputs.version }} + - name: Unpack and smoke actual architecture + shell: pwsh + env: + RELEASE_VERSION: ${{ needs.validate.outputs.version }} + RELEASE_RID: ${{ matrix.rid }} + run: | + $extension = if ($env:RELEASE_RID.StartsWith('win-')) { 'zip' } else { 'tar.gz' } + ./.github/scripts/Test-Binary.ps1 -Archive "artifacts/binaries/sharpclaw-$env:RELEASE_VERSION-$env:RELEASE_RID.$extension" -Runtime $env:RELEASE_RID -Version $env:RELEASE_VERSION + - uses: actions/upload-artifact@v7 + with: + name: binary-${{ matrix.rid }} + path: artifacts/binaries/* + if-no-files-found: error + + publish: + needs: [validate, binaries] + runs-on: ubuntu-latest + environment: nuget + permissions: + contents: write + steps: + - uses: actions/setup-dotnet@v6 + with: + dotnet-version: '10.0.x' + - uses: actions/download-artifact@v8 + with: + name: release-packages + path: release + - uses: actions/download-artifact@v8 + with: + pattern: binary-* + merge-multiple: true + path: release + - name: Validate complete release artifact set + shell: pwsh + env: + RELEASE_VERSION: ${{ needs.validate.outputs.version }} + run: | + $packages = @(Get-ChildItem release -Filter '*.nupkg') + $symbols = @(Get-ChildItem release -Filter '*.snupkg') + if ($packages.Count -ne 22 -or $symbols.Count -ne 22) { throw 'Release requires all 22 production packages and symbol packages.' } + foreach ($rid in @('win-x64','win-arm64','linux-x64','linux-arm64','osx-x64','osx-arm64')) { + $extension = if ($rid.StartsWith('win-')) { 'zip' } else { 'tar.gz' } + $archive = "release/sharpclaw-$env:RELEASE_VERSION-$rid.$extension" + if (!(Test-Path $archive) -or !(Test-Path "$archive.sha256")) { throw "Missing binary/checksum: $rid" } + $expected = (Get-Content "$archive.sha256" -Raw).Split(' ')[0] + if ((Get-FileHash $archive -Algorithm SHA256).Hash.ToLowerInvariant() -ne $expected) { throw "Checksum mismatch: $rid" } + } - name: Push to NuGet - run: dotnet nuget push './nupkg/*.nupkg' --api-key '${{ secrets.NUGET_API_KEY }}' --source https://api.nuget.org/v3/index.json --skip-duplicate + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + run: dotnet nuget push 'release/*.nupkg' --api-key "$NUGET_API_KEY" --source https://api.nuget.org/v3/index.json --skip-duplicate - name: Create GitHub release env: GH_TOKEN: ${{ github.token }} - run: gh release create '${{ github.ref_name }}' ./nupkg/*.nupkg ./nupkg/*.snupkg --verify-tag --generate-notes --title '${{ github.ref_name }}' + RELEASE_TAG: ${{ github.ref_name }} + run: gh release create "$RELEASE_TAG" release/* --verify-tag --generate-notes --title "$RELEASE_TAG" diff --git a/Directory.Packages.props b/Directory.Packages.props index 7aaa79e..d18933f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -23,7 +23,10 @@ + + + diff --git a/README.md b/README.md index 1df4a3b..a7b1bf0 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,16 @@ It is designed for: - [.NET SDK 10](https://dotnet.microsoft.com/download/dotnet/10.0) - Optional: [GitHub CLI](https://cli.github.com/) for release and repo automation +### Native binary or NuGet tool + +Version-tag releases are configured to include native binaries for Windows, Linux, and macOS on x64 and ARM64. Extract a matching archive and run `sharpclaw version`; basic commands need no installed .NET runtime. Semantic project loading and build/test verification still require the .NET SDK and restored assets. See [binary distribution](docs/binary-distribution.md) for packaging and smoke gates. + +```shell +dotnet tool install --global SharpClaw.Code.Cli --prerelease +``` + +See [.NET intelligence](docs/dotnet-intelligence.md), [verification](docs/verification.md), and [MCP server](docs/mcp-server.md) for permissions, prerequisites, and examples. + ### Build and test ```bash @@ -68,6 +78,12 @@ dotnet run --project src/SharpClaw.Code.Cli -- --auto-approve shell --auto-appro dotnet run --project src/SharpClaw.Code.Cli -- doctor dotnet run --project src/SharpClaw.Code.Cli -- status +# Verify without provider credentials; project execution needs explicit authorization +dotnet run --project src/SharpClaw.Code.Cli -- verify --scope all --permission-mode workspaceWrite --auto-approve shell + +# Serve MCP tools over stdio (read-only by default) +dotnet run --project src/SharpClaw.Code.Cli -- mcp serve + # Refresh and query the workspace knowledge index dotnet run --project src/SharpClaw.Code.Cli -- index refresh dotnet run --project src/SharpClaw.Code.Cli -- index query WidgetService @@ -123,8 +139,10 @@ Primary workflow modes: | Permission-aware tools | Route file, shell, and plugin-backed actions through explicit policy and approval decisions | | Provider abstraction | Run against Anthropic and OpenAI-compatible backends through a typed runtime surface | | Local runtime catalog | Surface Ollama, llama.cpp, and other OpenAI-compatible profiles with health, model discovery, and embedding defaults | -| MCP support | Register, supervise, and integrate MCP servers with explicit lifecycle state | +| MCP support | Register outbound servers and host permission-aware stdio or Streamable HTTP tools | | Plugins and skills | Extend the runtime with trusted plugin manifests and discoverable workspace skills | +| .NET intelligence | Resolve exact Roslyn symbols, find references and hierarchy, inspect compiler diagnostics, and rename with undo | +| Verification | Explicit build/test reports and opt-in bounded repair, with permission checks and durable recovery | | Workspace knowledge | Build a durable local index for lexical, symbol, and semantic workspace search | | Cross-session memory | Persist project and user memory so later sessions can recall repo-specific guidance and user preferences | | Structured telemetry | Emit runtime events and usage signals that support diagnostics, replay, and automation | diff --git a/SharpClawCode.Packages.slnf b/SharpClawCode.Packages.slnf index 97ce80f..faae1bf 100644 --- a/SharpClawCode.Packages.slnf +++ b/SharpClawCode.Packages.slnf @@ -10,6 +10,7 @@ "src/SharpClaw.Code.ExternalAgents/SharpClaw.Code.ExternalAgents.csproj", "src/SharpClaw.Code.Git/SharpClaw.Code.Git.csproj", "src/SharpClaw.Code.Infrastructure/SharpClaw.Code.Infrastructure.csproj", + "src/SharpClaw.Code.Mcp.Server/SharpClaw.Code.Mcp.Server.csproj", "src/SharpClaw.Code.Mcp/SharpClaw.Code.Mcp.csproj", "src/SharpClaw.Code.Memory/SharpClaw.Code.Memory.csproj", "src/SharpClaw.Code.Permissions/SharpClaw.Code.Permissions.csproj", diff --git a/SharpClawCode.sln b/SharpClawCode.sln index 6bb3d5f..d83e5fd 100644 --- a/SharpClawCode.sln +++ b/SharpClawCode.sln @@ -77,6 +77,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharpClaw.Code.ExternalAgen EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharpClaw.Code.WorkItems", "src\SharpClaw.Code.WorkItems\SharpClaw.Code.WorkItems.csproj", "{9C45962D-F544-474F-B67A-1736807EEC7E}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharpClaw.Code.Mcp.Server", "src\SharpClaw.Code.Mcp.Server\SharpClaw.Code.Mcp.Server.csproj", "{DDCEB402-1AEF-4D5B-89FD-9B59F3CD9A5B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -495,6 +497,18 @@ Global {9C45962D-F544-474F-B67A-1736807EEC7E}.Release|x64.Build.0 = Release|Any CPU {9C45962D-F544-474F-B67A-1736807EEC7E}.Release|x86.ActiveCfg = Release|Any CPU {9C45962D-F544-474F-B67A-1736807EEC7E}.Release|x86.Build.0 = Release|Any CPU + {DDCEB402-1AEF-4D5B-89FD-9B59F3CD9A5B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DDCEB402-1AEF-4D5B-89FD-9B59F3CD9A5B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DDCEB402-1AEF-4D5B-89FD-9B59F3CD9A5B}.Debug|x64.ActiveCfg = Debug|Any CPU + {DDCEB402-1AEF-4D5B-89FD-9B59F3CD9A5B}.Debug|x64.Build.0 = Debug|Any CPU + {DDCEB402-1AEF-4D5B-89FD-9B59F3CD9A5B}.Debug|x86.ActiveCfg = Debug|Any CPU + {DDCEB402-1AEF-4D5B-89FD-9B59F3CD9A5B}.Debug|x86.Build.0 = Debug|Any CPU + {DDCEB402-1AEF-4D5B-89FD-9B59F3CD9A5B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DDCEB402-1AEF-4D5B-89FD-9B59F3CD9A5B}.Release|Any CPU.Build.0 = Release|Any CPU + {DDCEB402-1AEF-4D5B-89FD-9B59F3CD9A5B}.Release|x64.ActiveCfg = Release|Any CPU + {DDCEB402-1AEF-4D5B-89FD-9B59F3CD9A5B}.Release|x64.Build.0 = Release|Any CPU + {DDCEB402-1AEF-4D5B-89FD-9B59F3CD9A5B}.Release|x86.ActiveCfg = Release|Any CPU + {DDCEB402-1AEF-4D5B-89FD-9B59F3CD9A5B}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -534,5 +548,6 @@ Global {C45BBEA7-5970-40BB-AE6D-B8F09D1E2EE1} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {C76D91F9-EAED-4C8B-A07C-70A3BF65E42A} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {9C45962D-F544-474F-B67A-1736807EEC7E} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {DDCEB402-1AEF-4D5B-89FD-9B59F3CD9A5B} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} EndGlobalSection EndGlobal diff --git a/docs/architecture.md b/docs/architecture.md index 90d2ccd..2b3ba68 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,5 +1,7 @@ # Architecture +Compiler-backed workspace inspection and symbol resolution live in Memory alongside the fast persisted lexical index. Semantic workspaces load lazily through Roslyn's build host and require explicit host-side authorization for project evaluation. Protocol models never expose Roslyn/MSBuild types. See [the .NET intelligence guide](dotnet-intelligence.md). + This document matches the **current** solution: `SharpClawCode.sln` with projects under `src/` and test projects under `tests/`. ## Solution structure diff --git a/docs/binary-distribution.md b/docs/binary-distribution.md new file mode 100644 index 0000000..0a68f70 --- /dev/null +++ b/docs/binary-distribution.md @@ -0,0 +1,37 @@ +# Self-contained binary distribution + +The release workflow creates six native CLI archives alongside the 22 production NuGet packages and their symbol packages. Windows archives are ZIP files; Linux and macOS archives use tar.gz. Each contains `sharpclaw` (or `sharpclaw.exe`), `LICENSE`, and `README.md`, with an adjacent SHA-256 checksum. + +| Runtime | Matching smoke runner | +| --- | --- | +| win-x64 | windows-2025 | +| win-arm64 | windows-11-arm | +| linux-x64 | ubuntu-24.04 | +| linux-arm64 | ubuntu-24.04-arm | +| osx-x64 | macos-15-intel | +| osx-arm64 | macos-15 | + +Runner labels follow the [GitHub hosted runner reference](https://docs.github.com/en/actions/reference/runners/github-hosted-runners). Each smoke script verifies its actual process OS and architecture before execution. Cross-publishing never substitutes for running the target executable. + +Extract the archive and run it directly: + +```shell +./sharpclaw version +./sharpclaw --cwd ./workspace --output-format json index refresh +./sharpclaw mcp serve --cwd ./workspace +``` + +Basic commands run without an installed .NET runtime. Roslyn/MSBuild semantic loading and build/test verification require a compatible installed .NET SDK and restored project assets. Restore remains explicit. Provider-dependent prompts also need provider configuration. The NuGet CLI tool retains its normal framework-dependent installation behavior. + +The single-file bundle includes native libraries and all Roslyn helper content, extracted by the .NET host as required. Publishing is self-contained with trimming and AOT disabled. Release-only `PackAsTool=false` resolves the SDK's single-file/tool packaging conflict without changing ordinary tool packages. Assembly and informational versions receive the tag identity. + +For local packaging and matching-platform verification: + +```powershell +pwsh -File .github/scripts/Publish-Binaries.ps1 -Runtime osx-arm64 -Version 0.1.0-preview.1 +pwsh -File .github/scripts/Test-Binary.ps1 -Runtime osx-arm64 -Version 0.1.0-preview.1 -Archive artifacts/binaries/sharpclaw-0.1.0-preview.1-osx-arm64.tar.gz +``` + +Smoke tests validate the checksum and three-file archive, unpack into a clean directory, start `version` with an empty PATH and isolated runtime roots, run offline non-.NET commands and SQLite index state, then launch the relocated executable from real SDK MCP clients. Those clients exercise read-only denials, semantic evaluation, reversible rename, and actual build/TRX test results. The installed SDK is used only for the SDK-dependent acceptance portion. + +The release workflow validates build, tests, scenarios, documentation, dependency audit, and local NuGet installation before binary jobs. NuGet push and GitHub release assembly wait for all six native smoke jobs. Local success does not prove the other platform jobs have run. No release is created until a version tag triggers the gated workflow. diff --git a/docs/dotnet-intelligence.md b/docs/dotnet-intelligence.md new file mode 100644 index 0000000..b63cd4f --- /dev/null +++ b/docs/dotnet-intelligence.md @@ -0,0 +1,40 @@ +# .NET intelligence + +SharpClaw's Memory subsystem provides lazy compiler-backed solution inspection and exact C# symbol resolution alongside the existing SQLite workspace index. Indexed `workspace_search` and `symbol_search` do not load MSBuild and retain their existing formats. + +Embedded hosts resolve `IDotNetWorkspaceTargetResolver` for non-executing discovery and `IDotNetWorkspaceSemanticService` for inspection and symbol queries. Requests select a workspace, optional contained target, configuration, and optional target framework. Discovery chooses an explicit target, one top-level `.sln`/`.slnx`, or one top-level `.csproj`; ambiguous workspaces require a selection. + +## Project evaluation permissions + +Loading through MSBuild can execute custom design-time targets. A host must provide `IDotNetWorkspaceEvaluationAuthorization` through its execution permission policy for a cold load or structural reload. A null authorization permits valid cached queries only. Authorization is a host-side interface, never an agent-controlled Boolean in a tool argument. + +Compiler queries exclude project-supplied analyzer and source-generator references. Generated-symbol support is therefore limited to source documents already materialized by approved builds. This service is not an execution sandbox. + +## Cache and SDK behavior + +The lifecycle-owned cache retains up to four compilation contexts and serializes use/load/eviction to prevent stale/disposed workspace access. Source content refresh does not reevaluate MSBuild. Changed compile-item paths, project/solution/props/targets/configuration, and restore assets invalidate loading and require fresh authorization. Literal imported build files are fingerprinted recursively, including those outside the root. Property-driven or wildcard imports require fresh evaluation authorization on each call. No filesystem watchers or hidden package restore are used. + +Roslyn 5.9 uses its bundled out-of-process build host to discover installed SDK/build tools and select the workspace SDK. A second in-process MSBuild Locator registration is unnecessary. The machine needs an appropriate .NET SDK and restored framework/package assets even when SharpClaw itself is a self-contained executable. + +Results use dependency-light Protocol records and generated JSON metadata. They distinguish target ambiguity, no target, permission denial, missing prerequisites, load failure, and partial loading. Symbol queries return candidates when names, overloaded signatures, or project contexts are ambiguous; selectors include container, kind, project/path, signature, and declaration position. Locations are one-based and paths are workspace-relative. + +Solution inspection reports evaluated project references, available restored target frameworks/package versions, and test-project heuristics. Multi-targeted projects identify the evaluated framework; one compilation does not imply all-framework semantic coverage. + +## Semantic tools + +The standard agent tool registry exposes `dotnet_solution_inspect`, `csharp_symbol_resolve`, `csharp_find_references`, `csharp_type_hierarchy`, and `csharp_diagnostics`. Each publishes an explicit JSON input schema and generated protocol output. `name` is exact; `container`, `kind`, `project`, `path`, declaration position, and `signature` can disambiguate it. References use compiler identity across project boundaries. Hierarchy includes base types, implemented interfaces, derived types and interface implementations. Diagnostics support project, path, severity and code filters. + +Use `target`, `configuration` and `targetFramework` to choose compilation context. References and diagnostics accept `offset` and `limit` (1–200); results include total/truncation metadata. Hierarchy bounds each relationship collection with `limit` and reports truncation. Source generators and project analyzers remain excluded. + +Tool execution first applies the normal caller permissions. A cold or structurally invalidated workspace additionally asks for `ShellExecution` permission under the original tool name and caller context, publishing permission events. No tool argument can grant that permission. Read-only cold queries return `PermissionDenied`; an already approved valid snapshot supports read-only queries without evaluation. + + +## Reversible rename + +`csharp_rename_symbol` selects an exact symbol and takes `newName`. Roslyn computes the complete solution edit; the tool rejects invalid identifiers, unresolved conflicts, new compiler errors, generated documents, paths outside the canonical workspace, and disagreeing linked-document edits. It does not rename files or change string/comment occurrences. + +The tool requires `FileSystemWrite` permission and follows existing workspace-write behavior. Cold evaluation additionally requires `ShellExecution`. A workspace lock serializes semantic writes across processes. Content preconditions run before the first write and again before each write, so external edits are preserved when detected. The complete successful edit set is recorded in chronological order for normal session checkpoint undo/redo. Source-only cache refresh observes rename, undo, redo and editor changes. + +On failure or cancellation, rollback uses a separate bounded token. It does not overwrite a file whose content no longer matches the planned after-state. Residual changes are recorded and failed results list their paths and rollback errors. The filesystem does not provide a multi-file atomic transaction: external editors can still race between a check and a write. Recovery/undo checks content again. + +Mutation snapshots preserve text and supported newline sequences. The existing text contract does not preserve original encoding or byte-order marks byte-for-byte; local writes use UTF-8. Cancellation still propagates after cleanup, with residual records retained for turn finalization. diff --git a/docs/mcp-server.md b/docs/mcp-server.md new file mode 100644 index 0000000..14885da --- /dev/null +++ b/docs/mcp-server.md @@ -0,0 +1,37 @@ +# Inbound MCP server + +`SharpClaw.Code.Mcp.Server` exposes the existing tool executor through the official MCP SDK. Register `AddSharpClawRuntime` and `AddSharpClawMcpServer` for an embedded host, then run `ISharpClawMcpServer.RunAsync` with a fixed workspace and explicit options. The server does not start the agent runtime, scheduler, outbound MCP clients, or provider authentication. + +```shell +sharpclaw mcp serve --cwd ./workspace +``` + +Stdio is the default. Stdout contains only MCP messages. The server defaults to `readOnly`, even though ordinary CLI commands default to `workspaceWrite`. It advertises `workspace_search`, `symbol_search`, `dotnet_solution_inspect`, `csharp_symbol_resolve`, `csharp_find_references`, `csharp_type_hierarchy`, `csharp_diagnostics`, and `verify_workspace`. + +Read-only queries can use an approved semantic cache, but a cold MSBuild load needs execution permission because project targets can run code. Verification is also an execution operation and is denied by the default mode. Permission denials return MCP tool errors and retain structured SharpClaw reports. Hosting never opens interactive approval prompts. + +Explicitly permit the required scopes to evaluate and verify a trusted workspace: + +```shell +sharpclaw mcp serve --cwd ./workspace --permission-mode workspaceWrite --auto-approve shell +``` + +Rename requires both `--allow-mutations` and a permission mode that permits writes; approval-sensitive writes additionally need a host-approved file scope. Hidden tools remain unavailable when called by name. Tool arguments cannot change the workspace, host identity, or permissions. + +```shell +sharpclaw mcp serve --cwd ./workspace --permission-mode workspaceWrite --auto-approve shell,file --allow-mutations +``` + +Elevated clients get independent durable SharpClaw sessions. Rename records checkpoint-linked mutation sets, including residual changes after failure or cancellation, so the normal session undo/redo surface can recover them. Client calls are serialized within their session, and semantic writes and verification also acquire workspace locks. HTTP and stdio share this bridge. Existing outbound MCP lifecycle and trust rules retain their behavior. + +## Streamable HTTP + +```shell +sharpclaw mcp serve --cwd ./workspace --transport http --port 7346 +``` + +Connect to `http://127.0.0.1:7346/mcp`. HTTP validates the Host header and any Origin header before the MCP endpoint. It uses stateful SDK sessions, with five-minute idle expiry and at most 64 idle sessions. Cancellation and shutdown dispose the transport. Each elevated client receives its own SharpClaw session and approval accounting. + +Set `SHARPCLAW_MCP_TOKEN` to a secret containing at least 16 non-whitespace characters before enabling elevated HTTP permissions. Clients must send `Authorization: Bearer ` on all requests. Embedded hosts can supply `BearerToken` directly. Tokens are not accepted as CLI arguments. A configured token is enforced even in read-only mode. + +Remote binding requires an explicit IP, `--allow-remote`, and authentication. Wildcard hosts are rejected. Deploy remote access behind a TLS endpoint. This host-selected token uses existing tenant/storage context and scoped approvals; it does not introduce a separate identity system. diff --git a/docs/runtime.md b/docs/runtime.md index a5b517d..8bd9475 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -176,3 +176,5 @@ Webhook and SSE event delivery both use the same `RuntimeEventEnvelope` shape, w | `ShareSessionService` | `src/SharpClaw.Code.Runtime/Workflow/ShareSessionService.cs` | | `ConversationCompactionService` | `src/SharpClaw.Code.Runtime/Workflow/ConversationCompactionService.cs` | | `WorkspaceHttpServer` | `src/SharpClaw.Code.Runtime/Server/WorkspaceHttpServer.cs` | + +Workspace prompt diagnostics use cached authorized verification results. [Verification](verification.md) describes the shared worker and execution policy. diff --git a/docs/superpowers/plans/2026-10-06-dotnet-intelligence-verification-mcp-distribution.md b/docs/superpowers/plans/2026-10-06-dotnet-intelligence-verification-mcp-distribution.md new file mode 100644 index 0000000..890ff93 --- /dev/null +++ b/docs/superpowers/plans/2026-10-06-dotnet-intelligence-verification-mcp-distribution.md @@ -0,0 +1,434 @@ +# .NET Intelligence, Verification, MCP Server, and Binary Distribution + +**Date:** 2026-10-06 + +**Repository baseline:** `main @ 72c29670c12cc11bd8c38e0447b1976460028367` + +**Input:** the supplied “.NET Intelligence + Verification Engine + MCP Server + Binary Distribution” specification, originally grounded at `a5553865a3b6c5a30bfe4df83735b3193f42f1c2`. + +**Status:** implementation plan; application code has not been changed. + +**Target:** .NET 10, C# 13; incremental vertical slices. + +## Recommended approach + +Keep the specification's nine phases. Reuse Memory for semantic knowledge, Tools for permission-gated operations, Runtime for verification and repair, and Commands for user-facing entry points. Add only one production project: `SharpClaw.Code.Mcp.Server`, when its working stdio slice is implemented. + +Start with a short compatibility and permission preflight, then prove solution inspection and exact symbol resolution. Do not build the entire contract/service catalog before that first slice works. Each phase below specifies concrete changes, verification, and an exit criterion. + +The repository inspection confirmed an installed .NET SDK of `10.0.100` and a clean checkout before this document was added. This planning pass did not run builds, tests, package publishing, or deployments; the baseline commands below are work for implementation. + +## 1. Current codebase and reuse map + +Paths in this document are repository-relative. “Add” paths are proposed files, not existing implementations. + +| Area | Verified current implementation | Implementation consequence | +| --- | --- | --- | +| Build settings | `Directory.Build.props` targets `net10.0`, C# `13.0`, XML documentation, analyzers, and vulnerability warnings as errors. | Preserve these settings; centrally manage every added package. | +| Dependencies | `Directory.Packages.props` pins `Microsoft.CodeAnalysis.CSharp` to `5.9.0` and `ModelContextProtocol` to `1.2.0`. | Validate compatible packages against these pins; do not independently upgrade one Roslyn assembly family. | +| Workspace knowledge | `Memory/Services/WorkspaceIndexService.cs` parses C# syntax and project-reference XML; `SqliteWorkspaceKnowledgeStore` persists chunks, symbols, and edges. | Add on-demand semantic loading alongside this index. Ordinary search must never trigger MSBuild loading. | +| Search | `Memory/Services/WorkspaceSearchService.cs`, `Tools/BuiltIn/WorkspaceSearchTool.cs`, and `SymbolSearchTool.cs` already expose indexed search. | Preserve existing tool names, result contracts, and SQLite formats. | +| Tool execution | `Tools/Registry/ToolRegistry.cs`, `Execution/ToolExecutor.cs`, and `ToolsServiceCollectionExtensions.cs` mediate discovery, permissions, execution, and events. | Register semantic adapters here; keep all callable operations on this execution path. | +| Provider discovery | `Agents/Services/AgentFrameworkBridge.cs` copies `ToolDefinition.InputSchemaJson` into provider definitions. | Supply actual schemas for every new tool; a CLR type name alone is insufficient. | +| Tool serialization | `Tools/Utilities/ToolJson.cs` currently uses reflection-based generic serialization. | New public contracts must explicitly use generated `ProtocolJsonContext` metadata; registering DTOs alone will not change this helper's behavior. | +| Permissions | `Permissions/Services/PermissionPolicyEngine.cs` allows file writes in `WorkspaceWrite`, requires approval for shell execution, and denies elevated operations in `ReadOnly`. | `RequiresApproval = true` does not guarantee an interactive prompt. Preserve the engine's actual mode rules. | +| Reversible changes | `Runtime/Turns/TurnMutationAccumulator.cs`, `Mutations/CheckpointMutationCoordinator.cs`, `MutationWorkspaceApplier.cs`, and `Sessions/Storage/FileMutationSetStore.cs` already support undo/redo. | Semantic rename must record complete before/after changes and reuse checkpoint persistence. | +| Mutation ordering | `TurnMutationAccumulator` stores operations in a concurrent bag and sorts by `OperationId`; built-in writes assign random GUID IDs. Undo reverses the saved operation list. | Fix chronological ordering before a repair loop can edit the same file repeatedly. | +| Diagnostics | `Runtime/Diagnostics/WorkspaceDiagnosticsService.cs` runs `dotnet build --no-restore`, parses output with a local regex, and caches snapshots. `PromptContextAssembler` invokes it during prompt assembly. | Extract one shared parser and close the existing implicit process-execution path while preserving the snapshot contract. | +| Process execution | `Infrastructure/Services/ProcessRunner.cs` uses `ProcessStartInfo.ArgumentList`, captures stdout/stderr, and kills the process tree on cancellation. | Reuse it; do not introduce shell-command strings or a second process runner. | +| Turn execution | `Runtime/Turns/DefaultTurnRunner.cs` executes one agent run and returns recorded mutations. `Orchestration/ConversationRuntime.cs` persists checkpoints and emits `TurnCompletedEvent(Succeeded: true)` on the normal path. | Add bounded verification before completion; propagate unsuccessful verification explicitly. | +| Public result | Internal `Runtime/Turns/TurnRunResult.cs` and public `Protocol/Commands/TurnExecutionResult.cs` are separate records. | Update both additively. Changing an imagined Protocol `TurnRunResult` would miss the real boundary. | +| Configuration | `Protocol/Models/OpenCodeParityModels.cs` contains `SharpClawConfigDocument`; Runtime configuration merges user/workspace documents. | Append optional verification configuration and update merging/defaults and tests. | +| MCP | `SharpClaw.Code.Mcp` is a client/supervisor. `McpCommandHandler` already supports list/status/register/start/stop/restart/doctor. | Keep client behavior intact; add `mcp serve` through an outer server host. | +| MCP trust | `Permissions/Rules/McpTrustRule.cs` requests approval for requests marked `Mcp` from an untrusted server, including read tools. | Distinguish inbound clients from the existing outbound-server trust category. Otherwise default noninteractive reads fail. | +| CLI composition | Handler registration lives in `Cli/Composition/CliServiceCollectionExtensions.cs`; there is no `CommandsServiceCollectionExtensions.cs`. | Register `verify` and `/verify` at the existing composition point. | +| CLI startup | `CliHostBuilder` clears log providers; `Program.cs` starts the Runtime host, including scheduled prompt services. | MCP needs deliberate protocol-only output and a host that does not start unrelated scheduled work. | +| Tests | Unit, integration, mock-provider, parity, and scenario harnesses already exist. MCP integration currently starts a fixture server and verifies discovery/lifecycle. | Extend these suites with real semantic fixtures and actual inbound MCP tool calls. | +| Distribution | CLI is a NuGet tool. `release.yml` already restores/audits/builds/tests/packs/verifies installation/pushes/creates a release. | Extend the release path rather than replace it. | +| Package checks | `SharpClawCode.Packages.slnf` includes 21 production projects; `Test-Packages.ps1` asserts exactly 21 packages. | A packaged MCP Server makes this 22; update the solution filter and smoke assertion together. | +| CI/docs | `ci.yml` runs Windows/Linux/macOS tests, package smoke checks, examples, scenario gates, VS Code compilation, and DocFX. | Add new checks to these lanes; update `docs/toc.yml` without editing generated `docs/api` or `docs/_site`. | + +Project prefixes in the table, such as `Memory/`, mean `src/SharpClaw.Code.Memory/`. + +## 2. Architecture decisions and necessary specification adjustments + +### 2.1 Keep the dependency graph acyclic + +`Runtime` already references `Tools`. Therefore a `VerifyWorkspaceTool` in Tools cannot reference a verification implementation in Runtime. + +Place `VerifyWorkspaceTool : SharpClawToolBase` in `Runtime/Verification/Tools/` and register it as `ISharpClawTool` from `RuntimeServiceCollectionExtensions`. The existing registry resolves the full DI tool collection, so this tool remains discoverable and executes through the same `ToolExecutor`. Keep `IVerificationService` and its implementation in Runtime. No new verification project or reverse project reference is needed. + +The MCP Server references Runtime, Tools, Protocol, Infrastructure, and the existing MCP project. CLI references the new server project for composition. Commands depends only on `ISharpClawMcpServer` in the existing MCP project. Runtime and the existing MCP client project must not reference the new server project. + +### 2.2 Semantic reads do not imply safe project evaluation + +MSBuild design-time loading invokes project targets. Microsoft's project-system documentation explicitly describes custom targets participating in design-time builds. **Inference:** a compiler-backed read operation can execute project-controlled code during loading, so treating an untrusted cold load as an ordinary read would contradict the repository's permission rule. [Design-time builds](https://github.com/dotnet/project-system/blob/main/docs/design-time-builds.md) + +Recommended acceptance adjustment: semantic queries can execute in `ReadOnly` against a previously approved, valid semantic snapshot. A cold load or reload requiring project evaluation must obtain execution authorization through the existing permission engine, or return a typed denial. A source-only refresh must also remain within the approved evaluation/generator policy. Do not silently use `DangerFullAccess`, equate process isolation with a sandbox, or execute external analyzers/generators as harmless reads. + +Phase 0 must demonstrate this behavior with a fixture target that writes a sentinel. The implementation must document the prerequisite rather than claim that every arbitrary solution can safely load under default read-only permissions. This is a security-driven change to the supplied cold-load expectations. + +### 2.3 All verification entry points share the same authorization boundary + +Use this path for agents, direct CLI, REPL, automatic verification, and MCP: + +```text +caller -> IToolExecutor("verify_workspace") -> permission policy + -> VerifyWorkspaceTool -> IVerificationService -> IProcessRunner +``` + +Handlers remain thin and reach the underlying verification service through the tool adapter. This deliberately tightens the specification's suggestion that handlers call the service directly: direct service invocation without authorization would let `sharpclaw verify --permission-mode readOnly` execute arbitrary build/test code. + +Embedded hosts can resolve the verification service for a trusted, already-authorized call; provide a documented permission-aware invocation path as the default. Keep permission orchestration outside parsers and the process worker so unit testing remains straightforward. + +`WorkspaceDiagnosticsService` currently bypasses that boundary during prompt assembly. Refactor it to consume authorized/cached verification diagnostics, retaining configured LSP metadata when execution is unavailable. Do not add a nested prompt-context/verification recursion. + +### 2.4 Preserve result and event compatibility + +Append optional `VerificationRunReport? Verification = null` to the internal and public turn results. Add a nullable verification field to the persisted turn if the durable snapshot must carry the report; events alone must not be the only route to a final machine-readable result. + +Register all new DTOs/enums/events in `ProtocolJsonContext`. Add stable event discriminators to `Protocol/Events/RuntimeEvent.cs` as well: generated metadata alone does not establish polymorphic event-log compatibility. + +Preserve existing required fields and `CommandResult`/`ToolResult` envelopes. Failed verification should retain its structured report even when `ToolResult.Succeeded` is false; the current base failure helper has no structured payload, so add a narrow typed overload or construct the envelope explicitly. + +### 2.5 Inbound MCP is a separate caller category + +Add an explicit `PermissionRequestSourceKind.McpClient` value for incoming MCP requests. Keep the existing `Mcp` value and `McpTrustRule` unchanged for requests initiated by registered external servers. This is the closest correct typed context to the proposal's generic “MCP” source. + +Inbound calls use `SourceName = "sharpclaw-mcp-server"`, `IsInteractive = false`, a fixed workspace, the configured mode, and a curated `AllowedTools` list. Normal boundary/mode/allowlist rules still run. Never spoof a trusted remote-server identity to make local read calls succeed. + +### 2.6 Distribute a self-contained host, with explicit SDK prerequisites + +A self-contained executable should run `version`, ordinary runtime features, and non-.NET tools without a preinstalled .NET runtime. Semantic MSBuild loading and `dotnet build/test` still need suitable SDK/build tools and target framework assets. Locator uses installed Visual Studio/.NET SDK build logic; report missing prerequisites structurally. [MSBuild Locator](https://github.com/microsoft/MSBuildLocator/blob/main/src/MSBuildLocator/README.md) + +Single-file publishing needs a compatibility proof, not just a successful publish command. Native dependencies may require extraction; assembly-file-path assumptions are incompatible with bundled assemblies. Inspect Roslyn build-host assets and SQLite native loading in the actual published output. Keep AOT and trimming disabled for this feature. [Single-file deployment](https://learn.microsoft.com/en-us/dotnet/core/deploying/single-file/overview) + +## 3. Implementation sequence + +### Phase 0 — Establish a tested baseline and resolve compatibility gates + +**Purpose:** avoid discovering permission and packaging blockers after most of the feature is built. + +- [x] Run the existing Release build/test/example/scenario/doc/package gates before changing application code. Record baseline failures separately from new regressions. +- [x] Validate centrally managed Roslyn Workspaces packages against the current `5.9.0` pin; verify compatible `Microsoft.Build.Locator` and matching MCP ASP.NET Core packages. Do not invent package versions or perform unrelated dependency upgrades. +- [x] In a temporary proof harness, load a tiny restored .NET solution, inspect framework references, and resolve an interface exactly. Check installed SDK discovery, `global.json`, missing SDK behavior, and required build-host files. +- [x] Exercise the sentinel design-time target to prove where execution authorization is required. Write down the approved-load/read-only-query behavior before implementing tool permissions. +- [x] Make a preliminary single-file publish on the current OS, checking whether Roslyn's build-host assets and SQLite can actually be used after moving the executable to a clean directory. This is a feasibility spike, not the release pipeline. + +**Exit:** baseline is recorded; package family alignment, permission behavior, SDK prerequisites, and publish-layout constraints are understood. A packaging limitation must have an explicit resolution before single-file completeness is claimed. + +### Phase 1 — Semantic foundation and one working vertical slice (R1–R3) + +**Modify:** `Directory.Packages.props`, `Memory/SharpClaw.Code.Memory.csproj`, `Memory/MemoryServiceCollectionExtensions.cs`, `Protocol/Serialization/ProtocolJsonContext.cs`. + +**Add incrementally:** + +- `Protocol/Models/DotNetWorkspaceRequest.cs`, target-resolution status/result types, solution/project/reference/package summaries, and initial C# symbol query/result/location types. +- `Memory/Abstractions/IDotNetWorkspaceSemanticService.cs`. +- `Memory/Services/DotNetWorkspaceTargetResolver.cs`. +- `Memory/Services/DotNetWorkspaceSemanticService.cs`. +- `Memory/Services/DotNetSemanticWorkspaceCache.cs` only when the working loader needs bounded ownership and reuse. + +**Tasks:** + +1. Add `Microsoft.CodeAnalysis.Workspaces.MSBuild`, `Microsoft.CodeAnalysis.CSharp.Workspaces`, and Locator references after Phase 0 validation. Keep Roslyn/MSBuild types private to Memory. +2. Support an explicit target in the public request; the supplied interface sketch otherwise has no place to express its own explicit-target rule. Resolve: explicit contained target, one top-level `.sln`/`.slnx`, one top-level `.csproj`, typed ambiguity, typed no-target. Enumerate deterministically and respect Windows path casing. +3. Register Locator once before touching MSBuild types. Detect incompatible existing registration in embedded hosts and return a clear load error rather than re-registering blindly. Respect workspace SDK selection; do not perform hidden restore. +4. Report partial/load failures and unresolved framework references distinctly. Do not return an apparently complete solution summary from a broken compilation. +5. Implement solution/project graph inspection and exact symbol lookup with candidates for ambiguity. Include container, kind, project/path, and optional declaration position or signature for overloaded symbols. Never turn a lexical name match into an asserted exact semantic identity. +6. Cache by canonical root, target, configuration, and framework context. Use async-safe per-workspace load coordination, bounded entries, and leases so active workspaces cannot be disposed during eviction. Cancellation/failure must not permanently poison a cache entry. +7. Invalidate project structure on solution/project/props/targets/package/global.json changes. Refresh changed source documents before semantic queries; cache freshness cannot depend only on project files. Revalidate imports/assets used by the loader conservatively. No filesystem watchers in this slice. +8. Keep multi-target semantic scope explicit. Report all declared target frameworks, but identify the evaluated framework in query results; do not claim all-framework coverage from one compilation. + +**Fixtures:** add a minimal checked-in fixture under `tests/Fixtures/DotNetSemantic/`, copied to temporary workspaces by tests. Include a library, consumer, interface, implementation, inherited type, overloaded symbol, and a small test project. Use existing test dependencies; explicitly provision fixture restore assets before semantic/build tests. + +**Tests:** solution/project discovery; `.slnx`; ambiguous/no target; target escape; exact/ambiguous symbol; missing SDK/assets; load failure recovery; concurrent loading; eviction/disposal; source and project edits invalidate results; existing indexed search never loads semantics. Include at least one actual cross-project reference resolution to prove framework references are usable. + +**Exit:** an approved fixture solution can be inspected and a symbol resolved exactly. Existing workspace search and persisted indexes remain compatible. Build the full solution at this boundary. + +### Phase 2 — Semantic read tools (R4) + +**Modify:** `Tools/ToolsServiceCollectionExtensions.cs`, `Tools/BuiltIn/SharpClawToolBase.cs` if typed serialization overloads are needed, `ProtocolJsonContext`, semantic service, and `Agents/Services/AgentFrameworkBridge.cs` tests. + +**Add:** reference/hierarchy/diagnostic Protocol DTOs and five focused tool classes in `Tools/BuiltIn/`: + +```text +dotnet_solution_inspect +csharp_symbol_resolve +csharp_find_references +csharp_type_hierarchy +csharp_diagnostics +``` + +**Tasks:** + +1. Use Roslyn symbol/reference APIs for definitions, references, implementations, and derived types. Deduplicate partial declarations and linked documents; include project/framework context and stable locations. +2. Expose compiler diagnostics with project/path/severity/code filters. Keep generator/analyzer execution behind the approved-evaluation boundary; do not widen “compiler diagnostics” into arbitrary analyzer execution implicitly. +3. Provide explicit input schemas and bounded/paged results with truncation metadata where large solutions require it. +4. Deserialize/serialize new tool contracts through generated `JsonTypeInfo`; preserve the legacy ToolJson path for existing tools until a separately justified migration. +5. Route adapters through `IToolRegistry` and `IToolExecutor`. Reads use the normal non-destructive query scope, with approved semantic loading as a separate prerequisite. + +**Tests:** references across projects; interface implementations; class/interface/record hierarchy; ambiguous overloads; diagnostic filters; generated/linked document handling; provider-advertised schemas; read-only query success after approved load; unapproved cold load denial; JSON round-trips and absence of Roslyn/MSBuild objects. + +**Parity:** `semantic_symbol_resolution` with a provisioned local fixture or a deterministic semantic seam; no network access. + +**Exit:** providers discover and invoke all five tools through the existing bridge; results are compiler-backed and permission behavior is explicit. + +### Phase 3 — Permission-aware, reversible semantic rename (R5) + +**Add:** rename request/change/plan/result Protocol DTOs and `Tools/BuiltIn/CSharpRenameSymbolTool.cs`. + +**Modify:** semantic service, tool registration, generated metadata, and mutation ordering support in `Runtime/Turns/TurnMutationAccumulator.cs`. + +**Tasks:** + +1. Generate a full Roslyn rename plan without writing through `MSBuildWorkspace.TryApplyChanges`. Resolve one exact symbol and validate the new identifier and rename conflicts. +2. Reject generated/unwritable documents and every canonical path outside the workspace, including linked-file and symlink escapes. Combine duplicate physical-file edits only if their final contents agree. +3. Read and retain all before/after text, validate every path, and compare the current content to the plan immediately before writing. Do not overwrite concurrent user changes. Preserve supported newline behavior; the existing text-only mutation contract does not guarantee byte-identical encoding/BOM restoration, so test and document that limit or extend encoding metadata additively if required. +4. Apply through `IFileSystem` only after permission evaluation: `FileSystemWrite`, destructive, requires approval. `ReadOnly` denies; `WorkspaceWrite` follows the current allow rules rather than introducing a forced interactive prompt. +5. Record the complete committed change set through `IToolMutationRecorder`. On failure/cancellation, roll back with an independent bounded cleanup token; a cancelled execution token must not prevent cleanup. +6. If rollback leaves residual changes, record their actual final state and include rollback failures in the failed result. Never leave recoverable changes invisible or report partial success as a complete rename. +7. Replace random-ID sorting with actual recording order for repeated mutations. Keep operation IDs and durable JSON formats stable. Serialize same-workspace semantic writes and use content preconditions against external edits. +8. Invalidate semantic snapshots after apply, rollback, undo, and redo, including changes performed outside semantic tools. A source fingerprint check remains necessary for external editor changes. + +**Tests:** multi-file rename compiles; overload ambiguity/conflicts; denial writes nothing; workspace-write policy; outside/linked/symlink paths; concurrent edits; write failure and rollback failure; cancellation; all files recorded; real undo/redo restores the initial/final text; two successive edits to one file replay in the correct order. + +**Parity:** `semantic_rename_mutation_recorded`. + +**Exit:** rename is semantically correct, wholly permission-gated, and reversible through the existing checkpoint system. No refactoring catalog or file-renaming feature is added. + +### Phase 4 — Structured verification core (R6–R9) + +**Add under Runtime:** + +```text +Verification/IVerificationService.cs +Verification/VerificationService.cs +Verification/IAffectedProjectResolver.cs +Verification/AffectedProjectResolver.cs +Verification/IDotNetDiagnosticParser.cs +Verification/DotNetDiagnosticParser.cs +Verification/ITestResultParser.cs +Verification/TrxTestResultParser.cs +``` + +Use one explicit orchestration service initially; split build/test workers only when their responsibilities justify it. Add Protocol verification enums, request/policy, build/step/test/run reports, and failure-reason types as needed by this slice. + +**Tasks:** + +1. Use typed argument arrays with `IProcessRunner`, explicit working directory, consistent configuration/framework selection, timeouts, and caller cancellation. Default restore off. Missing assets, unavailable SDK, permission denial, timeout, process failure, and no .NET target must remain distinguishable. +2. Parse build diagnostics once from stdout/stderr. Handle drive-letter paths, spaces, ranges, project suffixes, location-free SDK/MSBuild errors, and duplicate output. Do not infer success from an empty diagnostic list; use exit codes too. +3. Run tests with `--no-build --no-restore --logger trx` and a unique temporary results directory. Parse XML with DTD/external entity processing disabled. Merge multiple project/framework TRX files without collisions, retaining failed names/messages/stacks and project attribution. +4. Treat a nonzero test exit or missing/malformed expected result files as an explicit failed/incomplete test step. Distinguish “no test projects” from a passing test run. Clean temp data best-effort and bound report/log sizes. +5. Define scope behavior: `Build` builds the target; `Tests` runs existing matching outputs and reports a build prerequisite failure if unavailable; `All` builds then tests all selected test projects; `Affected` builds/tests the affected closure. Stop tests after a failed prerequisite build. +6. Determine ownership using evaluated compile items, then reverse dependency edges to include consumers and relevant test projects. Consider linked files, deletes, project/config/build-file changes, and unknown file ownership. Conservatively fall back to the explicit solution/project; a non-.NET workspace returns `Skipped` with a reason. +7. For runtime calls use the turn's recorded paths. For standalone `Affected`, use a supplied change list or existing Git inspection; when changes/ownership cannot be established, verify the target conservatively. Never treat a missing change list as proof that no verification is needed. +8. Extract the diagnostic parser from `WorkspaceDiagnosticsService`. Preserve `WorkspaceDiagnosticsSnapshot`, configured LSP metadata, and bounded caching. Wire permission-aware diagnostic refresh in Phase 5 before exposing new execution surfaces. +9. Ensure this service is callable without a model/provider or API key. + +**Tests:** fake process runner success/failure; missing assets; structured diagnostics; Windows paths; malformed/multiple TRX; skipped tests; absent test output; build/test configuration mismatch; cancellation kills process tree; timeout; affected reverse graph; conservative fallback; no-target skip; restore is never requested implicitly. + +**Integration:** one restored fixture builds and runs real tests on Windows/Linux/macOS. Restore/provision fixture dependencies during explicit test setup/CI restore, not inside verification. Test/parity execution itself must not fetch packages. + +**Exit:** build/test verification produces stable generated JSON without an LLM; prompt diagnostic parsing is shared, not duplicated. + +### Phase 5 — Verification tool, CLI, and REPL (R10–R11) + +**Add:** `Runtime/Verification/Tools/VerifyWorkspaceTool.cs`, `Commands/Handlers/VerifyCommandHandler.cs`. + +**Modify:** Runtime composition, CLI composition, command tests/renderers, diagnostic refresh orchestration, and generated metadata. + +**Tasks:** + +1. Register the Runtime-owned tool in the existing `ISharpClawTool` collection. Use `ShellExecution`, `RequiresApproval = true`, `IsDestructive = false`, and an explicit schema. +2. Implement `VerifyCommandHandler` as `ICommandHandler` and `ISlashCommandHandler`, following `IndexCommandHandler`'s pattern. Use the existing recursive `--cwd`, `--permission-mode`, `--output-format`, and scoped approval settings rather than duplicate global options. +3. Add `--scope affected|build|tests|all`, `--target`, and `--restore`. Validate options and use the same parser for equivalent slash-command arguments. Default to `affected`, with conservative target fallback. +4. Invoke via `IToolExecutor`, construct real caller context, preserve REPL session settings, and render the typed verification report within the existing command envelope. Expose structured failed reports in JSON. +5. Map pass to exit 0; map failed/denied/prerequisite errors to nonzero. A no-.NET skip must be visibly skipped rather than falsely reported as verified. Follow existing cancellation exit conventions and test them. +6. Remove the unconditional process call from prompt assembly: use cached compiler/verification diagnostics or an explicitly authorized refresh. Read-only/headless prompts must not build before a permission decision. + +**Tests:** CLI and `/verify` scope/target/restore/output binding; no provider credentials; text/JSON envelopes; real build failure nonzero with code/path/line; tool discovery; read-only process denial; workspace-write approval flow; noninteractive denial; approved scoped shell execution; prompt assembly never bypasses permissions. + +**Parity:** `verification_build_success`, `verification_build_failure`, `verification_permission_denied` using deterministic process results. + +**Exit:** humans, scripts, agents, and embedded callers share the same worker and authorization path. + +### Phase 6 — Opt-in automatic verification and bounded repair (R12–R13) + +**Add:** Protocol `VerificationOptions`/policy and four verification event records; Runtime `IVerificationPolicyResolver`, policy resolver, `IVerificationLoopCoordinator`, coordinator, and a bounded failure-context formatter. + +**Modify:** `OpenCodeParityModels.cs`, `SharpClawWorkflowMetadataKeys.cs`, `SharpClawConfigService.cs`, Runtime composition, `DefaultTurnRunner.cs`, `TurnRunResult.cs`, `ConversationRuntime.cs`, public `TurnExecutionResult.cs`, persisted turn/result rendering as needed, `PromptInvocationService.cs`, `RuntimeEvent.cs`, and `ProtocolJsonContext.cs`. + +**Tasks:** + +1. Append optional configuration; default to disabled, affected scope, post-mutation execution, repair limit 2, restore off. Use the four supplied metadata keys and resolve request overrides over workspace/user defaults. Validate invalid scope, negative/excessive budgets, and nullable backward compatibility. +2. Run only after supported source mutations and only when explicitly enabled. Preserve existing plan/spec/research modes; do not treat their generated planning artifacts as automatic coding-verification triggers. Shell/plugin/external-agent writes currently lack guaranteed mutation records: state that initial automatic detection covers recorded file changes, and use conservative change detection only when it is proven. +3. Invoke verification through the same `verify_workspace` permission boundary. Denial/prerequisite errors/non-.NET skips do not trigger speculative agent repair. +4. Implement an iterative loop in the coordinator, not recursion into `DefaultTurnRunner` or `IConversationRuntime`. With budget 2, permit at most initial verification plus two repair-and-verification passes. Reuse the selected agent, session/turn IDs, primary mode, allowlist, permissions, and mutation recorder. +5. Supply bounded structured compiler/test failures plus current-turn context. Keep previous assistant/tool history relevant to the repair; avoid raw megabyte logs. Aggregate token usage, provider events/requests, tool results, and mutations across attempts rather than retaining only the last run. +6. Publish started/completed/repair-started/repair-completed events once, with attempt numbers and correlation IDs. Persist through existing event infrastructure. On recovery/resume, do not silently reset or multiply the repair budget; retain attempt accounting using the durable event/state seam. +7. Keep all actual edits in one ordered reversible change set for the logical turn. Persist recoverable mutations even when verification, repair, or cancellation fails after edits. Use bounded finalization separate from cancelled execution; do not lose before/after data by throwing before checkpoint persistence. +8. Propagate the final report into internal/public/durable results. The normal completion path must not emit `Succeeded: true` after exhausted failed verification. Update prompt exit codes/renderers so enabled verification failure is visible to scripts as well as humans. Preserve old behavior when verification is absent/disabled. +9. Serialize verification per workspace where overlapping build/test output would collide; existing session locks alone do not coordinate two sessions in the same workspace. + +**Tests:** disabled path unchanged; no mutations means no run; mode exclusions; metadata/config precedence; zero/two repair limits; max attempts never exceeded; permission denial means no process/repair; structured repair context; aggregate usage/results; same-file edit/repair undo/redo; failed repair/cancellation retains mutations; recovery does not reset budget; polymorphic persisted events and old config/turn payloads round-trip. + +**Exit:** enabled coding turns verify, repair within the configured budget, and report truthful structured completion without bypassing permissions or breaking recovery. + +### Phase 7 — Working stdio MCP server + +**Add:** `src/SharpClaw.Code.Mcp.Server/SharpClaw.Code.Mcp.Server.csproj` and only the host/bridge/registration files needed for the working slice. Add `ISharpClawMcpServer` and typed server options to the existing MCP project's `Abstractions/` and `Models/`. + +**Modify:** solution, package solution filter, CLI project/composition, `McpCommandHandler`, source-kind contract/tests, package smoke checks, and SDK documentation. If convenient embedded server registration is exposed by `SharpClaw.Code`, add its outer-project reference; never add a Runtime-to-server reference. + +**Tasks:** + +1. Use the already-pinned official MCP SDK. Register a curated tool set explicitly; use supported typed/programmatic APIs, not broad assembly scanning. The SDK supports tool creation and structured tool results; confirm the exact API against the pinned package. [Official MCP tools documentation](https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/concepts/tools/tools.md) +2. Add `sharpclaw mcp serve` under the existing command. Default to stdio, `ReadOnly`, mutations disabled. The current global CLI default is `WorkspaceWrite`: detect whether a permission override was explicitly supplied instead of inheriting that default accidentally. +3. Host protocol traffic without Spectre output, banners, JSON command summaries, or approval prompts on stdout. Route logs to stderr. Do not start the outer Runtime scheduler for an MCP-only process; introduce a narrow MCP host composition/startup path and reuse shared services. Cancellation, EOF, and shutdown must dispose the server and semantic cache cleanly. +4. Expose `workspace_search`, `symbol_search`, and the five semantic tools. Expose `verify_workspace` with execution semantics; read-only default calls to it are denied. Keep the Phase 0 approved-evaluation prerequisite for semantic cold loads explicit. +5. Include `csharp_rename_symbol` only when `AllowMutations` is true and the selected mode permits it. Enforce the same allowlist on `tools/call`, not just `tools/list`; manually naming a hidden tool must not work. +6. Bridge every allowed call through `IToolExecutor` using inbound MCP context. Map denials/failures to MCP tool errors and preserve typed structured content. Do not catch cancellation as success. +7. Use valid SharpClaw session/turn correlation where events are persisted. Never invent nonempty session IDs with no session store entry: `ToolExecutor` treats them as durable. Mutation-enabled calls need a real session, recorder, checkpoint, and mutation-set persistence through the existing coordinator so MCP rename remains undoable. Read-only calls can use a non-durable context if no session is created. +8. Preserve existing MCP client lifecycle and outbound trust behavior. Do not make the inbound server auto-connect to configured external MCP servers or require model credentials. +9. Include the new packable project in `SharpClawCode.Packages.slnf` and update `Test-Packages.ps1` from 21 to 22 expected production packages. Verify local NuGet install includes the new CLI dependency and public server API. + +**Integration:** launch the real `sharpclaw mcp serve` process with the official `StdioClientTransport`/client, negotiate, list tools, invoke indexed search without credentials, invoke semantics with an approved fixture, inspect structured results, verify default rename absence and direct-call denial, verify read-only build denial, and shut down. Assert every stdout message is valid MCP traffic. Separately enable permitted mutations and prove recorded rename/undo. No external network dependency. + +**Exit:** an external MCP client can use real SharpClaw tools through normal execution and permission seams. Default exposure is safe and the existing MCP client subsystem still passes its tests. + +### Phase 8 — Streamable HTTP MCP transport + +**Modify:** MCP Server project/host, matching central package entry, server options, command binding, and transport integration tests. + +**Tasks:** + +1. Add the official `ModelContextProtocol.AspNetCore` package matched to the existing SDK family. Use the same bridge and schemas as stdio. Official transport documentation identifies this package and its MCP endpoint mapping. [MCP transports](https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/concepts/transports/transports.md) +2. Default to `127.0.0.1` and a configurable port; validate host/port and reject implicit wildcard binding. Require explicit configuration for non-loopback exposure. +3. Validate accepted Host/Origin values for local HTTP access, and preserve request cancellation. Keep caller/session/approval/mutation state isolated between clients. +4. Use the existing authenticated approval/host context seams when elevated execution is enabled. Non-loopback exposure must be explicitly authenticated/configured; do not create a separate enterprise identity platform for this slice. +5. Test both transports against the same exposure and permission matrix; test malformed requests, cancellation, shutdown, and client isolation. + +**Exit:** HTTP clients invoke the same bounded tool surface, with loopback-only defaults and no stdio regression. This phase does not delay completion of Phase 7. + +### Phase 9 — Six self-contained binary artifacts (R14) + +**Modify:** `.github/workflows/release.yml` and CI where binary smoke belongs. Add focused cross-platform scripts such as `.github/scripts/Publish-Binaries.ps1` and `Test-Binary.ps1` only when shared workflow logic warrants them. + +**Tasks:** + +1. Separate release validation/package creation, RID publish-and-smoke jobs, and final publish/release assembly. Preserve restore, audit, warning-free build, tests, package install verification, NuGet push, and GitHub release creation. Do not push packages/create a release until all required binary checks pass. +2. Publish the CLI project, not the whole solution, with release-time settings: + + ```text + dotnet publish src/SharpClaw.Code.Cli/SharpClaw.Code.Cli.csproj + --configuration Release --runtime --self-contained true + -p:PublishSingleFile=true -p:PublishTrimmed=false -p:PublishAot=false + -p:Version= -p:PackageVersion= + --output + ``` + + Resolve any NuGet-tool/self-contained publish conflict with a release-only `PackAsTool=false` override if the Phase 0 proof requires it. Do not set a global RID, single-file setting, or change normal NuGet tool packaging. +3. Add release-only native extraction settings where the proof requires them, such as `IncludeNativeLibrariesForSelfExtract=true`. Resolve Roslyn helper content explicitly; do not accidentally omit it when staging an executable-only archive. If the proposed three-file archive cannot support the loader, record and resolve that specification deviation before release rather than silently dropping semantic support. +4. Produce `sharpclaw--.zip` for `win-x64`/`win-arm64`; `.tar.gz` for `linux-x64`/`linux-arm64`/`osx-x64`/`osx-arm64`. Stage the executable as `sharpclaw.exe` or `sharpclaw`, plus `LICENSE` and `README.md`. Preserve Unix executable mode. Exclude PDBs from binary archives while retaining existing NuGet symbol packages. +5. Pass the tag version into assembly metadata as well as PackageVersion. The current version command reads the entry assembly's version; package version alone does not update what the executable reports. Preserve the current JSON shape, and add informational-version detail only additively if prerelease identity needs exposure. +6. Smoke each actual RID executable on a matching OS/architecture runner or a documented equivalent. Select runner labels based on current account/repository availability; do not count cross-publishing an ARM64 binary as executing it. Missing execution coverage is a release gap, not a passed smoke test. +7. Unpack into a clean directory before testing. Run the executable directly for `version`, an offline non-.NET command, SQLite-backed state, stdio MCP handshake/tool call, and semantic/build/test behavior with an installed SDK. Also prove `version` works in an environment without a .NET runtime/SDK. Provider-dependent `doctor` checks may legitimately report unconfigured providers and need assertions that distinguish that from packaging failure. +8. Gather archives and `.nupkg`/`.snupkg` files into the same GitHub release after all gates pass. Check the expected artifact set and add checksums if practical. + +**Exit:** all six binary archives are published and directly smoke-tested; basic execution requires no preinstalled .NET runtime; SDK-dependent features correctly describe their prerequisites; NuGet tool and SDK package installation still work. + +## 4. Cross-phase verification and quality gates + +Use focused unit/integration filters while implementing each slice, then build affected projects with warnings as errors. Broaden to full solution checks at phase boundaries that change dependencies, public contracts, composition, or runtime behavior. + +Run the complete final gates: + +```bash +dotnet restore SharpClawCode.sln +dotnet build SharpClawCode.sln --configuration Release --no-restore --warnaserror +dotnet test SharpClawCode.sln --configuration Release --no-build +dotnet build examples/WebApiAgent/WebApiAgent.csproj --configuration Release +dotnet build examples/MinimalConsoleAgent/MinimalConsoleAgent.csproj --configuration Release +dotnet build examples/WorkerServiceHost/WorkerServiceHost.csproj --configuration Release +dotnet build examples/McpToolAgent/McpToolAgent.csproj --configuration Release +dotnet run --project src/SharpClaw.Code.Cli/SharpClaw.Code.Cli.csproj --configuration Release --no-build -- test run +dotnet run --project src/SharpClaw.Code.Cli/SharpClaw.Code.Cli.csproj --configuration Release --no-build -- test gates +pwsh -File .github/scripts/Test-VulnerablePackages.ps1 -Target SharpClawCode.sln +pwsh -File .github/scripts/Test-Packages.ps1 +dotnet tool restore +dotnet docfx docs/docfx.json --warningsAsErrors +``` + +Keep the existing coverage floor, Windows/Linux/macOS lanes, VS Code compilation, and package smoke checks passing. Run final binary tests on all six target architectures. Avoid network dependencies in parity and MCP tests; package/fixture provisioning is an explicit setup step. + +Required manual acceptance in disposable fixture workspaces: + +- Resolve a definition and cross-project references through an agent using a mock provider or explicitly configured provider. +- Rename an interface, build the updated solution, then undo/redo the whole multi-file change. +- Run `verify --scope all` and inspect typed passing build/test reports. +- Introduce a compile error and verify nonzero exit plus structured code/path/line. +- Enable repair limit 2, trigger a test-sensitive failure, and prove the attempt ceiling, truthful failure/pass result, usage accounting, and reversibility. +- Connect a real MCP client, invoke safe tools, and demonstrate default mutation/build denial plus permitted mutation recording when explicitly enabled. +- Run each downloaded/unpacked executable directly; separate no-runtime host execution from SDK-backed feature acceptance. + +## 5. Documentation deliverables + +Update documentation alongside each working slice, not after the entire implementation: + +| Slice | Documentation | +| --- | --- | +| Semantic foundation/read tools | Add `docs/dotnet-intelligence.md`; update `docs/architecture.md`, `docs/tools.md`, and relevant README capability text. Explain approved project evaluation, ambiguity, cache freshness, and SDK/framework prerequisites. | +| Rename | Document mutation recording, rollback limits, read-only denial, workspace-write policy, linked/generated file handling, and undo/redo. | +| Verification | Add `docs/verification.md`; update `docs/runtime.md`, `docs/tools.md`, and `docs/testing.md`. Document scopes, restore-off behavior, failed JSON reports, authorization, and non-.NET skip. | +| Automatic repair | Document disabled default, configuration/metadata precedence, exact budget semantics, recorded-mutation detection limits, cancellation/recovery, and completion/exit behavior. | +| MCP | Add `docs/mcp-server.md`; update `docs/mcp.md` to distinguish client/server roles, stdio output rules, caller categories, transport defaults, SDK prerequisite, and mutation exposure. | +| Distribution | Add `docs/distribution.md`; update README installation instructions and artifact names, supported RIDs, SDK-dependent capabilities, and direct executable use. | + +Add authored pages to `docs/toc.yml`. Do not hand-edit generated API YAML or site HTML. README differentiation should describe compiler-aware .NET intelligence, structured verification, and MCP-hosted tooling only as the corresponding slices become implemented and tested. + +## 6. Completion and handoff + +The complete implementation must satisfy this path through shared services: + +```text +inspect approved .NET workspace -> resolve exact symbols/references +-> permission-aware reversible change -> affected-project selection +-> build/test -> structured failures -> bounded repair -> re-verify +``` + +CLI, agent runtime, MCP, and embedded hosts must expose that same behavior without duplicated business logic. Preserve existing JSON envelopes, index/storage formats, plugin execution, MCP client behavior, and disabled-default verification behavior. + +Keep Native AOT, arbitrary C# skill compilation, in-process plugin loading, an IDE/LSP replacement, additional refactorings, and distributed build/workflow systems out of scope. + +At delivery, report implemented phases, actual commands/tools, public contract additions, tests and platform evidence, archive/package artifacts, and any deliberately deferred or externally blocked acceptance. Do not equate “published successfully” with “executed on every RID,” or “build passed” with “tests ran.” + +No branches, commits, PRs, issues, or production releases should be created as part of this planning request. Future implementation should start with Phase 0, then finish Phase 1 before proceeding. + +## Implementation evidence + +- Phase 0 complete: Release warning-as-error build, 318 baseline tests, all four scenario gates, 21-package install smoke, vulnerability audit, examples, and DocFX passed on macOS arm64 with SDK 10.0.100. +- Phase 0 adaptation: Roslyn 5.9 owns MSBuild Locator inside its bundled build host. The tested implementation uses matched CSharp.Workspaces/Workspaces.MSBuild packages without registering another in-process Locator. Sentinel targets confirmed that evaluation executes project code. +- Single-file feasibility passed after relocating executables: Roslyn load plus SQLite-backed index. Release needs PackAsTool=false, native extraction, and all-content extraction so helper files remain available. Basic execution and SDK-dependent features have separate prerequisites. +- Phase 1 complete: deterministic target discovery, permission-required lazy loading, cache capacity four, source refresh and project-input invalidation, typed SDK/restore/load failures, project graph and exact lookup. Eight focused unit tests and ten real SDK integration tests passed; full solution build and documentation checked. + +- Phase 2 complete: five registered schema-bearing semantic read tools, exact cross-project references, type relationships, bounded compiler diagnostics, generated JSON, and execution-aware cold evaluation. Thirteen focused SDK integration tests passed. Offline semantic parity added; expanding its catalog required updating the old fixed-count assertion. + +- Phase 3 complete: Roslyn rename plan/conflict validation, permission-gated multi-file writes, optimistic content checks, bounded rollback/residual recording, chronological mutations, and canonical-path undo fix. Seven real integration cases passed, including two successive renames, a build, durable undo/redo, injected write/rollback failure, cancellation, external edits, ambiguity, and generated-source rejection. Rename parity added. + +- Phase 4 complete: typed build/test worker, evaluated source ownership/reverse closure, explicit restore, secure TRX parser, shared diagnostic parser, process timeout/cancellation, workspace serialization and bounded reports. Three parser/graph unit tests and three real/fault-controlled integration scenarios passed, including a real passing and failing xUnit fixture. Approved SDK/package bootstrap sources stay in compilations but are excluded from workspace query locations; their metadata stamps require fresh evaluation after changes. + +- Phase 5 complete: verify_workspace, CLI verify and /verify share generated reports and the shell permission boundary. Cached prompt diagnostics execute no processes. CLI/REPL integration and permission/budget tests passed; three deterministic verification parity scenarios added. + +- Phase 6 complete: disabled-default fieldwise configuration/metadata policy, iterative bounded repairs, durable attempt events, aggregated usage/tool/provider accounting, final report propagation and truthful completion/exit, and interrupted-turn mutation journaling. Eight integration cases and six config/policy unit cases passed, including a real test-sensitive repair and durable undo after failures/cancellation. Phases 0–5 full regressions passed (359 tests at the Phase 5 boundary); Phase 6 full regressions underway. + +- Phase 6 full gates: 372 tests passed (275 unit, 77 integration, 20 parity); Release and DocFX passed with warnings as errors. +- Phase 7: real official-SDK stdio clients passed handshake, curated discovery, search, cold-evaluation and verification denial, hidden rename denial, explicitly elevated semantic loading, and durable rename undo. A narrow direct SDK transport leaves Runtime hosted services stopped; package set is now 22. + +- Phase 8 full gates: 383 tests passed (275 unit, 88 integration, 20 parity); warning-free Release build. Stateful HTTP shares the stdio bridge, rejects wildcard/implicit remote exposure, validates Host/Origin, requires tokens for elevated/remote hosts, expires idle sessions, and isolates durable client state. Literal external build imports now invalidate semantic snapshots; unresolved property/wildcard imports require fresh evaluation authorization. + +- Phase 9 implementation: release validation, six matching architecture publish/smoke lanes, final artifact/checksum validation, and gated NuGet/GitHub publication are implemented. Single-file publishing embeds native and Roslyn helper content; ordinary NuGet tool packaging remains framework-dependent. Informational version is additive in the version JSON payload. +- Native acceptance completed locally for osx-arm64: a relocated three-file archive starts with empty PATH and isolated runtime roots, reports the exact preview identity, supports offline non-.NET verification and SQLite index state, and passes six real native MCP checks (default denials, elevated semantics, durable rename undo, real build/TRX tests, workflow restrictions, protocol-only stdout and clean EOF shutdown). Archive: `artifacts/binaries/sharpclaw-0.1.0-preview.1-osx-arm64.tar.gz`, with adjacent SHA-256 checksum. +- Package smoke now isolates its consumer cache and maps SharpClaw package IDs to the local feed. This fixed a genuine stale same-version package false positive. All 22 production packages and 22 symbol packages were created; the new server API compiles from the aggregate SDK package and the freshly installed CLI runs. +- Final gates: Release solution/example build has zero warnings/errors; 389 tests pass (275 unit, 94 integration, 20 parity). Coverage collection passed the existing 38 percent floor at 41.24 percent. All four scenario gates, DocFX with warnings as errors, vulnerability audit of 35 projects, VS Code compilation, and actionlint validation of CI/release workflows pass. Additional strict wire and linked external-source tests pass in the final suite. Generated scenario-report churn was restored; fresh results remain in ignored `artifacts/testing/`. +- Release acceptance still pending externally: win-x64, win-arm64, linux-x64, linux-arm64 and osx-x64 must execute their configured matching CI jobs. No cross-publish is counted as runtime acceptance, and no packages, tag, commit, PR or GitHub release were published by this implementation run. diff --git a/docs/testing.md b/docs/testing.md index 3fd0a17..ad39a5c 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -74,3 +74,5 @@ The scenario harness lives in **`SharpClaw.Testing.Abstractions`**, **`SharpClaw ## CI CI restores and builds the full solution with warnings treated as errors, explicitly builds every example host, runs `dotnet test`, then runs the scenario harness through `sharpclaw test run` and `sharpclaw test gates`. It also enforces the line-coverage floor, compiles the VS Code extension, audits dependencies, and smoke-installs production packages on Linux, Windows, and macOS. Parity tests use temp directories under **`Path.GetTempPath()`** and avoid network. + +Build/test verification and its offline SDK fixture acceptance are documented in [Verification](verification.md). diff --git a/docs/toc.yml b/docs/toc.yml index 8a3d0a3..16f7237 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -4,6 +4,8 @@ href: architecture.md - name: Runtime href: runtime.md +- name: .NET intelligence + href: dotnet-intelligence.md - name: Providers href: providers.md - name: Tools and permissions @@ -26,3 +28,12 @@ href: testing.md - name: API reference href: api/toc.yml + +- name: Verification + href: verification.md + +- name: MCP server + href: mcp-server.md + +- name: Binary distribution + href: binary-distribution.md diff --git a/docs/tools.md b/docs/tools.md index 6846639..0ffabd2 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -57,3 +57,7 @@ Use **Protocol** types for stable payloads where appropriate; tool JSON helpers - End-to-end **LLM-driven tool use** in the default agent path is **not** wired through **`AgentFrameworkBridge`** today; **`AgentRunContext.ToolExecutor`** is available for future or alternate agent implementations. See [permissions.md](permissions.md) for gates on destructive and elevated tools. + +Compiler-backed .NET tools are described in [.NET intelligence](dotnet-intelligence.md). They share normal tool discovery, explicit schemas, and a separate execution decision before cold MSBuild evaluation. + +The permission-gated `verify_workspace` tool and CLI/REPL equivalents are documented in [Verification](verification.md). diff --git a/docs/verification.md b/docs/verification.md new file mode 100644 index 0000000..cd499f7 --- /dev/null +++ b/docs/verification.md @@ -0,0 +1,40 @@ +# Verification + +The runtime's `IVerificationService` produces typed build/test reports without a model or provider key. Its worker executes project code and is a trusted-host API; untrusted callers must use the permission-aware `verify_workspace` tool. + +Scopes are `build` (selected target), `tests` (existing matching outputs), `all` (build then test), and `affected` (evaluated source owners plus reverse dependencies). Missing, deleted, unknown or configuration/build inputs conservatively select the whole target. Standalone affected calls inspect Git when no change list is supplied. A non-.NET workspace returns `Skipped`, with its reason. + +Restore defaults off. Missing assets and missing SDKs remain explicit prerequisites; tests use `--no-build --no-restore` and `tests` scope does not build missing outputs. Build exit codes determine success even when no diagnostics were printed. Failed prerequisite builds prevent tests from starting. + +Reports contain target/configuration context, timestamps, selected projects, step exit codes, deduplicated compiler/MSBuild diagnostics, test counters, failed cases and bounded logs. TRX results use a unique run directory, secure XML parsing, and project attribution; missing or malformed expected results fail verification. No test projects is an explicit skipped test section, not evidence that tests passed. Process timeouts and caller cancellation are distinct. A workspace file lock serializes overlapping verification runs. + +SDKs, target framework assets and restored test dependencies are still required for these capabilities, including when the host is a self-contained binary. Fixture provisioning is explicit; verification never silently restores dependencies. + + +## CLI, REPL and agents + +Use `sharpclaw verify --scope all --target MySolution.slnx`, `sharpclaw verify --scope build`, or `/verify --scope affected`. CLI and REPL share scope/target/restore/configuration/framework/timeout parsing. Existing global `--cwd`, `--permission-mode`, `--output-format` and scoped approval settings apply. JSON preserves the usual command envelope with a typed report in its data payload. Failed and denied checks return nonzero; non-.NET skips remain explicit in the report. `--restore` is the only CLI flag that permits restore. + +`verify_workspace` publishes an explicit agent schema and uses `ShellExecution`, because MSBuild and tests execute project code. Read-only denies it before any process work. Noninteractive workspace-write calls require explicit shell auto-approval settings; existing approval budgets are honored. REPL permission and workflow overrides are preserved. + +Prompt assembly never builds a workspace. It retains configured LSP metadata and consumes recently cached diagnostics from an authorized verification report. Embedded hosts should call `IToolExecutor.ExecuteAsync("verify_workspace", argumentsJson, callerContext, token)` for mediated execution; direct worker access is reserved for trusted, already-authorized orchestration. + +## Automatic verification and repair + +Automatic verification is disabled by default. Add this to workspace/user JSONC to enable it: + +```json +{"verification":{"enabled":true,"scope":"affected","runAfterMutatingTurn":true,"maxRepairIterations":2,"allowRestore":false}} +``` + +Workspace fields override user fields. Request metadata overrides those defaults with `sharpclaw.verification.enabled`, `sharpclaw.verification.scope`, `sharpclaw.verification.maxRepairIterations`, and `sharpclaw.verification.allowRestore`. Invalid booleans, scopes and budgets fail validation. Budgets are 0–5; default 2 means one initial verification plus at most two agent repairs and their checks. + +The loop runs after recorded source/project/build-input mutations in build mode. Plan, spec and research modes do not trigger it. Shell, plugin and external-agent writes without mutation records are outside initial automatic detection. No recorded mutation means no automatic check. Permission denial, missing prerequisites and skipped checks do not trigger agent repair; only build/test failures do. + +Repair reuses the selected agent, session/turn, permissions, allowlist, history and ordered recorder. Bounded compiler diagnostics and failed test details form repair input. Usage, tool results and provider request/event pairs are aggregated across completed passes. A provider that throws before returning usage cannot supply a reliable token count; no usage is fabricated for that failed pass. + +Verification and repair lifecycle events persist through the standard event infrastructure. A repair-start event consumes its budget before agent execution. Same-turn recovery retains consumed attempts and cannot increase the initial ceiling. Resuming the session with a new prompt starts a new logical turn. + +All edits share one checkpoint mutation set. Failed verification emits `Succeeded=false`, persists the report on the turn and public result, and produces a nonzero prompt exit. Interrupted/cancelled turns finalize captured edits with a separate bounded persistence token, allowing inspection or undo. Normal mutation persistence retries do not append duplicate checkpoint IDs. Workspace verification locks prevent overlapping build/test output, while existing session locks preserve turn ordering. + +Workspace evaluation and each child process have the requested timeout bound. With no explicit framework selection, multi-framework TRX results retain unknown framework attribution rather than claiming all results came from the single evaluated compilation. diff --git a/src/SharpClaw.Code.Cli/Composition/CliServiceCollectionExtensions.cs b/src/SharpClaw.Code.Cli/Composition/CliServiceCollectionExtensions.cs index b76a43f..5c4c4d0 100644 --- a/src/SharpClaw.Code.Cli/Composition/CliServiceCollectionExtensions.cs +++ b/src/SharpClaw.Code.Cli/Composition/CliServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using SharpClaw.Code.Acp; using SharpClaw.Code.Commands; +using SharpClaw.Code.Mcp.Server; using SharpClaw.Code.Commands.Options; using SharpClaw.Testing.Cli; @@ -19,6 +20,7 @@ public static class CliServiceCollectionExtensions public static IServiceCollection AddSharpClawCli(this IServiceCollection services) { services.AddSharpClawAcp(); + services.AddSharpClawMcpServer(); services.AddSharpClawTestingCli(); services.AddSingleton(); services.AddSingleton(); @@ -56,6 +58,7 @@ public static IServiceCollection AddSharpClawCli(this IServiceCollection service services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -94,6 +97,7 @@ public static IServiceCollection AddSharpClawCli(this IServiceCollection service services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/SharpClaw.Code.Cli/Program.cs b/src/SharpClaw.Code.Cli/Program.cs index 0985206..febb898 100644 --- a/src/SharpClaw.Code.Cli/Program.cs +++ b/src/SharpClaw.Code.Cli/Program.cs @@ -4,15 +4,13 @@ using System.CommandLine; using var host = CliHostBuilder.BuildHost(args); -await host.StartAsync(); - -try -{ - var commandFactory = host.Services.GetRequiredService(); - var rootCommand = await commandFactory.CreateRootCommandAsync(); - return await rootCommand.Parse(args).InvokeAsync(); -} -finally -{ - await host.StopAsync(); -} +var commandFactory = host.Services.GetRequiredService(); +var rootCommand = await commandFactory.CreateRootCommandAsync(); +var parsed = rootCommand.Parse(args); +// MCP owns its transport lifetime. Starting Runtime would also start schedulers and outbound clients. +var isMcpServer = parsed.CommandResult.Command.Name == "serve" + && parsed.CommandResult.Parent is System.CommandLine.Parsing.CommandResult parent + && parent.Command.Name == "mcp"; +if (!isMcpServer) await host.StartAsync(); +try { return await parsed.InvokeAsync(); } +finally { if (!isMcpServer) await host.StopAsync(); } diff --git a/src/SharpClaw.Code.Cli/Rendering/TextOutputRenderer.cs b/src/SharpClaw.Code.Cli/Rendering/TextOutputRenderer.cs index 2c06882..f66c082 100644 --- a/src/SharpClaw.Code.Cli/Rendering/TextOutputRenderer.cs +++ b/src/SharpClaw.Code.Cli/Rendering/TextOutputRenderer.cs @@ -48,6 +48,7 @@ public Task RenderTurnExecutionResultAsync(TurnExecutionResult result, Cancellat AnsiConsole.MarkupLine("[yellow]Prompt completed with no output.[/]"); } + if (result.Verification is { } verification) AnsiConsole.WriteLine($"Verification: {verification.Status}. {verification.Summary}"); return Task.CompletedTask; } } diff --git a/src/SharpClaw.Code.Cli/SharpClaw.Code.Cli.csproj b/src/SharpClaw.Code.Cli/SharpClaw.Code.Cli.csproj index ddcfc7b..5edaa80 100644 --- a/src/SharpClaw.Code.Cli/SharpClaw.Code.Cli.csproj +++ b/src/SharpClaw.Code.Cli/SharpClaw.Code.Cli.csproj @@ -17,6 +17,7 @@ + diff --git a/src/SharpClaw.Code.Commands/Handlers/McpCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/McpCommandHandler.cs index b039f27..3db6bda 100644 --- a/src/SharpClaw.Code.Commands/Handlers/McpCommandHandler.cs +++ b/src/SharpClaw.Code.Commands/Handlers/McpCommandHandler.cs @@ -16,7 +16,8 @@ public sealed class McpCommandHandler( IMcpRegistry mcpRegistry, IMcpServerHost mcpServerHost, IMcpDoctorService mcpDoctorService, - OutputRendererDispatcher outputRendererDispatcher) : ICommandHandler + OutputRendererDispatcher outputRendererDispatcher, + ISharpClawMcpServer? inboundServer = null) : ICommandHandler { /// public string Name => "mcp"; @@ -28,6 +29,7 @@ public sealed class McpCommandHandler( public Command BuildCommand(GlobalCliOptions globalOptions) { var command = new Command(Name, Description); + command.Subcommands.Add(BuildServeCommand(globalOptions)); command.Subcommands.Add(BuildListCommand(globalOptions)); command.Subcommands.Add(BuildStatusCommand(globalOptions)); command.Subcommands.Add(BuildRegisterCommand(globalOptions)); @@ -38,6 +40,35 @@ public Command BuildCommand(GlobalCliOptions globalOptions) return command; } + private Command BuildServeCommand(GlobalCliOptions globalOptions) + { + var command = new Command("serve", "Serves SharpClaw tools over stdio; defaults to readOnly without mutations."); + var transport = new Option("--transport") { DefaultValueFactory = _ => "stdio", Description = "stdio or http (Streamable HTTP)." }; + transport.Validators.Add(result => { if (result.GetValueOrDefault() is not ("stdio" or "http")) result.AddError("Transport must be stdio or http."); }); + var host = new Option("--host") { DefaultValueFactory = _ => "127.0.0.1" }; + var port = new Option("--port") { DefaultValueFactory = _ => 7346 }; + var remote = new Option("--allow-remote") { Description = "Explicitly permit authenticated non-loopback HTTP." }; + command.Options.Add(transport); command.Options.Add(host); command.Options.Add(port); command.Options.Add(remote); + var mutations = new Option("--allow-mutations") { Description = "Expose rename when the host permission mode permits writes." }; + command.Options.Add(mutations); + command.SetAction(async (parseResult, cancellationToken) => + { + var context = globalOptions.Resolve(parseResult); + var explicitMode = parseResult.GetResult(globalOptions.PermissionModeOption) is { Implicit: false } + || parseResult.GetValue(globalOptions.YoloOption); + if (inboundServer is null) throw new InvalidOperationException("Register AddSharpClawMcpServer to host inbound MCP."); + await inboundServer.RunAsync(new SharpClaw.Code.Mcp.Models.SharpClawMcpServerOptions( + context.WorkingDirectory, + explicitMode ? context.PermissionMode : PermissionMode.ReadOnly, + parseResult.GetValue(mutations), context.ApprovalSettings, context.HostContext, + parseResult.GetValue(transport) == "http" ? SharpClaw.Code.Mcp.Models.SharpClawMcpTransport.Http : SharpClaw.Code.Mcp.Models.SharpClawMcpTransport.Stdio, + parseResult.GetValue(host)!, parseResult.GetValue(port), parseResult.GetValue(remote), + Environment.GetEnvironmentVariable("SHARPCLAW_MCP_TOKEN"), context.PrimaryMode), cancellationToken).ConfigureAwait(false); + return 0; + }); + return command; + } + private Command BuildListCommand(GlobalCliOptions globalOptions) { var command = new Command("list", "Lists registered MCP servers."); diff --git a/src/SharpClaw.Code.Commands/Handlers/VerifyCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/VerifyCommandHandler.cs new file mode 100644 index 0000000..6ced375 --- /dev/null +++ b/src/SharpClaw.Code.Commands/Handlers/VerifyCommandHandler.cs @@ -0,0 +1,72 @@ +using System.CommandLine; +using System.Text.Json; +using SharpClaw.Code.Commands.Models; +using SharpClaw.Code.Commands.Options; +using SharpClaw.Code.Protocol.Abstractions; +using SharpClaw.Code.Protocol.Commands; +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.Sessions.Abstractions; +using SharpClaw.Code.Tools.Abstractions; +using SharpClaw.Code.Tools.Models; + +namespace SharpClaw.Code.Commands; + +/// Shares option parsing and permission-aware verification across CLI and REPL callers. +public sealed class VerifyCommandHandler(IToolExecutor tools, ISessionStore sessions, IRuntimeHostContextAccessor host, OutputRendererDispatcher renderer, ReplInteractionState repl) : ICommandHandler, ISlashCommandHandler +{ + /// + public string Name => "verify"; + /// + public string CommandName => Name; + /// + public string Description => "Builds/tests the .NET workspace through execution permissions, without a model."; + /// + public Command BuildCommand(GlobalCliOptions globalOptions) + { + var command = new Command(Name, Description); + Configure(command, (parsed, arguments, token) => ExecuteAsync(globalOptions.Resolve(parsed), arguments, isSlash: false, token)); + return command; + } + /// + public Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken) + { + var root = new RootCommand(Description); + var effective = context with { PermissionMode = repl.PermissionModeOverride ?? context.PermissionMode, PrimaryMode = repl.PrimaryModeOverride ?? context.PrimaryMode, ApprovalSettings = repl.ApprovalSettingsOverride ?? context.ApprovalSettings }; + Configure(root, (_, arguments, token) => ExecuteAsync(effective, arguments, isSlash: true, token)); + return root.Parse(command.Arguments).InvokeAsync(cancellationToken: cancellationToken); + } + private static void Configure(Command command, Func> execute) + { + var scope = new Option("--scope") { DefaultValueFactory = _ => "affected", Description = "affected, build, tests, or all." }; + scope.AcceptOnlyFromAmong("affected", "build", "tests", "all"); + var target = new Option("--target") { Description = "Contained solution/project target." }; + var restore = new Option("--restore") { Description = "Explicitly restore before verification." }; + var configuration = new Option("--configuration") { DefaultValueFactory = _ => "Debug", Description = "Build/test configuration." }; + var framework = new Option("--framework") { Description = "Optional target framework." }; + var timeout = new Option("--timeout") { DefaultValueFactory = _ => 120, Description = "Per-step timeout in seconds (1–3600)." }; + timeout.Validators.Add(result => { if (result.GetValueOrDefault() is < 1 or > 3600) result.AddError("--timeout must be between 1 and 3600 seconds."); }); + foreach (var option in new Option[] { scope, target, restore, configuration, framework, timeout }) command.Options.Add(option); + command.SetAction((parsed, token) => execute(parsed, new(Enum.Parse(parsed.GetValue(scope)!, true), parsed.GetValue(target), parsed.GetValue(configuration)!, parsed.GetValue(framework), parsed.GetValue(restore), TimeoutSeconds: parsed.GetValue(timeout)), token)); + } + private async Task ExecuteAsync(CommandExecutionContext context, VerifyWorkspaceArguments arguments, bool isSlash, CancellationToken token) + { + using var hostScope = host.BeginScope(context.HostContext); + var session = context.SessionId is not null ? await sessions.GetByIdAsync(context.WorkingDirectory, context.SessionId, token).ConfigureAwait(false) + : isSlash ? await sessions.GetLatestAsync(context.WorkingDirectory, token).ConfigureAwait(false) : null; + if (context.SessionId is not null && session is null) + { + await renderer.RenderCommandResultAsync(new(false, 1, context.OutputFormat, "Session was not found.", null), context.OutputFormat, token).ConfigureAwait(false); + return 1; + } + var toolContext = new ToolExecutionContext(session?.Id ?? "", session?.ActiveTurnId ?? "", context.WorkingDirectory, context.WorkingDirectory, context.PermissionMode, context.OutputFormat, null, + IsInteractive: (isSlash || !Console.IsInputRedirected) && context.OutputFormat != OutputFormat.Json, PrimaryMode: context.PrimaryMode, ApprovalSettings: context.ApprovalSettings); + var envelope = await tools.ExecuteAsync("verify_workspace", JsonSerializer.Serialize(arguments, ProtocolJsonContext.Default.VerifyWorkspaceArguments), toolContext, token).ConfigureAwait(false); + var result = envelope.Result; + var commandResult = new CommandResult(result.Succeeded, result.ExitCode ?? (result.Succeeded ? 0 : 1), context.OutputFormat, result.Succeeded ? ReportSummary(result) : result.ErrorMessage ?? "Verification failed.", result.StructuredOutputJson); + await renderer.RenderCommandResultAsync(commandResult, context.OutputFormat, token).ConfigureAwait(false); + return commandResult.ExitCode; + } + private static string ReportSummary(ToolResult result) => result.StructuredOutputJson is { } json ? JsonSerializer.Deserialize(json, ProtocolJsonContext.Default.VerificationRunReport)?.Summary ?? "Verification completed." : result.Output ?? "Verification completed."; +} diff --git a/src/SharpClaw.Code.Commands/Handlers/VersionCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/VersionCommandHandler.cs index 3cd17db..ed9943c 100644 --- a/src/SharpClaw.Code.Commands/Handlers/VersionCommandHandler.cs +++ b/src/SharpClaw.Code.Commands/Handlers/VersionCommandHandler.cs @@ -46,7 +46,7 @@ public async Task ExecuteAsync(SlashCommandParseResult command, CommandExec private static CommandResult CreateResult(OutputFormat outputFormat) { var version = Assembly.GetEntryAssembly()?.GetName().Version?.ToString() ?? "0.0.0"; - var payload = JsonSerializer.Serialize(new VersionPayload(version)); + var payload = JsonSerializer.Serialize(new VersionPayload(version, Assembly.GetEntryAssembly()?.GetCustomAttribute()?.InformationalVersion)); return new CommandResult( Succeeded: true, @@ -56,5 +56,5 @@ private static CommandResult CreateResult(OutputFormat outputFormat) DataJson: payload); } - private sealed record VersionPayload(string Version); + private sealed record VersionPayload(string Version, string? InformationalVersion = null); } diff --git a/src/SharpClaw.Code.Commands/PromptInvocationService.cs b/src/SharpClaw.Code.Commands/PromptInvocationService.cs index a9c36d8..5203b43 100644 --- a/src/SharpClaw.Code.Commands/PromptInvocationService.cs +++ b/src/SharpClaw.Code.Commands/PromptInvocationService.cs @@ -2,6 +2,7 @@ using SharpClaw.Code.Providers.Models; using SharpClaw.Code.Protocol.Commands; using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Models; using SharpClaw.Code.Runtime.Abstractions; namespace SharpClaw.Code.Commands; @@ -54,7 +55,7 @@ await outputRendererDispatcher.RenderCommandResultAsync( .ExecutePromptAsync(prompt, context.ToRuntimeCommandContext(isInteractive: isInteractive), cancellationToken) .ConfigureAwait(false); await outputRendererDispatcher.RenderTurnExecutionResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); - return 0; + return result.Verification?.Status is VerificationStatus.Failed or VerificationStatus.Cancelled ? 1 : 0; } catch (ProviderExecutionException exception) { diff --git a/src/SharpClaw.Code.Commands/Repl/ReplHost.cs b/src/SharpClaw.Code.Commands/Repl/ReplHost.cs index 6af6d02..6b39a03 100644 --- a/src/SharpClaw.Code.Commands/Repl/ReplHost.cs +++ b/src/SharpClaw.Code.Commands/Repl/ReplHost.cs @@ -197,7 +197,7 @@ private async Task ExecutePromptAsync(string prompt, CommandExecutionContex cancellationToken).ConfigureAwait(false); await outputRendererDispatcher.RenderTurnExecutionResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false); - return 0; + return result.Verification?.Status is VerificationStatus.Failed or VerificationStatus.Cancelled ? 1 : 0; } catch (ProviderExecutionException exception) { diff --git a/src/SharpClaw.Code.Mcp.Server/SharpClaw.Code.Mcp.Server.csproj b/src/SharpClaw.Code.Mcp.Server/SharpClaw.Code.Mcp.Server.csproj new file mode 100644 index 0000000..8df802c --- /dev/null +++ b/src/SharpClaw.Code.Mcp.Server/SharpClaw.Code.Mcp.Server.csproj @@ -0,0 +1,9 @@ + + Permission-aware SharpClaw MCP server transports. + + + + + + + diff --git a/src/SharpClaw.Code.Mcp.Server/SharpClawMcpHttpServer.cs b/src/SharpClaw.Code.Mcp.Server/SharpClawMcpHttpServer.cs new file mode 100644 index 0000000..3a6b528 --- /dev/null +++ b/src/SharpClaw.Code.Mcp.Server/SharpClawMcpHttpServer.cs @@ -0,0 +1,92 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using SharpClaw.Code.Mcp.Models; +using SharpClaw.Code.Protocol.Enums; + +namespace SharpClaw.Code.Mcp.Server; + +public sealed partial class SharpClawMcpServer +{ + private async Task RunHttpAsync(SharpClawMcpServerOptions options, CancellationToken cancellationToken) + { + var endpoint = ValidateHttpOptions(options); + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = [], ApplicationName = typeof(SharpClawMcpServer).Assembly.GetName().Name }); + builder.WebHost.UseUrls(endpoint.AbsoluteUri.TrimEnd('/')); + builder.Logging.ClearProviders(); + builder.Logging.AddConsole(console => console.LogToStandardErrorThreshold = LogLevel.Trace); + builder.Services.AddMcpServer(server => server.ServerInfo = new() + { Name = "SharpClaw.Code", Version = typeof(SharpClawMcpServer).Assembly.GetName().Version?.ToString() ?? "1.0.0" }) + .WithHttpTransport(transport => + { + transport.Stateless = false; + transport.IdleTimeout = TimeSpan.FromMinutes(5); + transport.MaxIdleSessionCount = 64; + }) + .WithListToolsHandler((request, ct) => bridge.ListAsync(options, ct)) + .WithCallToolHandler((request, ct) => bridge.CallAsync(options, request, ct)); + await using var app = builder.Build(); + app.Use(async (context, next) => + { + if (!AcceptHost(context.Request.Host, endpoint)) + { + context.Response.StatusCode = StatusCodes.Status400BadRequest; + return; + } + var origin = context.Request.Headers.Origin; + if (origin.Count > 0 && (origin.Count != 1 || !Uri.TryCreate(origin[0], UriKind.Absolute, out var uri) + || uri.Scheme != endpoint.Scheme || uri.Authority != context.Request.Host.Value + || uri.AbsolutePath != "/" || uri.Query.Length != 0 || uri.Fragment.Length != 0 || uri.UserInfo.Length != 0)) + { + context.Response.StatusCode = StatusCodes.Status403Forbidden; + return; + } + if (options.BearerToken is not null && !HasToken(context.Request, options.BearerToken)) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + return; + } + await next(context).ConfigureAwait(false); + }); + app.MapMcp("/mcp"); + await app.RunAsync(cancellationToken).ConfigureAwait(false); + } + + /// Validates explicit HTTP exposure; elevated and remote hosts require a secret. + public static Uri ValidateHttpOptions(SharpClawMcpServerOptions options) + { + if (options.Port is < 1 or > 65535) throw new ArgumentOutOfRangeException(nameof(options), "HTTP port must be 1..65535."); + var localhost = options.Host.Equals("localhost", StringComparison.OrdinalIgnoreCase); + if (!localhost && (!IPAddress.TryParse(options.Host, out var address) || address.Equals(IPAddress.Any) || address.Equals(IPAddress.IPv6Any))) + throw new ArgumentException("HTTP host must be localhost or an explicit non-wildcard IP address.", nameof(options)); + var loopback = localhost || IPAddress.IsLoopback(IPAddress.Parse(options.Host)); + if (!loopback && !options.AllowRemote) throw new ArgumentException("Non-loopback HTTP requires AllowRemote.", nameof(options)); + if ((!loopback || options.PermissionMode != PermissionMode.ReadOnly) && string.IsNullOrWhiteSpace(options.BearerToken)) + throw new ArgumentException("Remote or elevated HTTP requires SHARPCLAW_MCP_TOKEN or a host-supplied BearerToken.", nameof(options)); + if (options.BearerToken is not null && (options.BearerToken.Length < 16 || options.BearerToken.Any(char.IsWhiteSpace))) + throw new ArgumentException("HTTP bearer token must contain at least 16 characters without whitespace.", nameof(options)); + return new UriBuilder("http", options.Host, options.Port).Uri; + } + + private static bool AcceptHost(HostString host, Uri endpoint) + { + if (host.Port != endpoint.Port) return false; + var name = host.Host.Trim('[', ']'); + if (endpoint.IsLoopback) return name.Equals("localhost", StringComparison.OrdinalIgnoreCase) + || IPAddress.TryParse(name, out var address) && IPAddress.IsLoopback(address); + return name.Equals(endpoint.Host.Trim('[', ']'), StringComparison.OrdinalIgnoreCase); + } + + private static bool HasToken(HttpRequest request, string expected) + { + var header = request.Headers.Authorization; + if (header.Count != 1 || header[0] is not { } value || !value.StartsWith("Bearer ", StringComparison.Ordinal)) return false; + return CryptographicOperations.FixedTimeEquals(SHA256.HashData(Encoding.UTF8.GetBytes(value[7..])), SHA256.HashData(Encoding.UTF8.GetBytes(expected))); + } +} diff --git a/src/SharpClaw.Code.Mcp.Server/SharpClawMcpServer.cs b/src/SharpClaw.Code.Mcp.Server/SharpClawMcpServer.cs new file mode 100644 index 0000000..34cab3b --- /dev/null +++ b/src/SharpClaw.Code.Mcp.Server/SharpClawMcpServer.cs @@ -0,0 +1,47 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using SharpClaw.Code.Mcp.Abstractions; +using SharpClaw.Code.Mcp.Models; + +namespace SharpClaw.Code.Mcp.Server; + +/// Runs the official SDK transport with an explicit permission-aware tool bridge. +public sealed partial class SharpClawMcpServer(SharpClawMcpToolBridge bridge) : ISharpClawMcpServer +{ + /// + public async Task RunAsync(SharpClawMcpServerOptions options, CancellationToken cancellationToken) + { + bridge.Validate(options); + if (options.Transport == SharpClawMcpTransport.Http) + { + await RunHttpAsync(options, cancellationToken).ConfigureAwait(false); + return; + } + var serverOptions = new McpServerOptions + { + ServerInfo = new Implementation { Name = "SharpClaw.Code", Version = typeof(SharpClawMcpServer).Assembly.GetName().Version?.ToString() ?? "1.0.0" }, + Capabilities = new ServerCapabilities { Tools = new ToolsCapability() }, + Handlers = new McpServerHandlers + { + ListToolsHandler = (request, ct) => bridge.ListAsync(options, ct), + CallToolHandler = (request, ct) => bridge.CallAsync(options, request, ct), + }, + }; + await using var server = McpServer.Create(new StdioServerTransport(serverOptions, NullLoggerFactory.Instance), serverOptions); + await server.RunAsync(cancellationToken).ConfigureAwait(false); + } +} + +/// Registers the inbound host; shared runtime services must also be registered. +public static class McpServerServiceCollectionExtensions +{ + /// Adds the server without starting runtime or outbound MCP hosted services. + public static IServiceCollection AddSharpClawMcpServer(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/src/SharpClaw.Code.Mcp.Server/SharpClawMcpToolBridge.cs b/src/SharpClaw.Code.Mcp.Server/SharpClawMcpToolBridge.cs new file mode 100644 index 0000000..19fecaf --- /dev/null +++ b/src/SharpClaw.Code.Mcp.Server/SharpClawMcpToolBridge.cs @@ -0,0 +1,122 @@ +using System.Runtime.CompilerServices; +using System.Text.Json; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Mcp.Models; +using SharpClaw.Code.Permissions.Models; +using SharpClaw.Code.Protocol.Abstractions; +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Runtime.Abstractions; +using SharpClaw.Code.Runtime.Mutations; +using SharpClaw.Code.Runtime.Turns; +using SharpClaw.Code.Sessions.Abstractions; +using SharpClaw.Code.Tools.Abstractions; +using SharpClaw.Code.Tools.Models; + +namespace SharpClaw.Code.Mcp.Server; + +/// Maps inbound MCP calls to the existing executor and durable mutation coordinator. +public sealed class SharpClawMcpToolBridge( + IToolRegistry registry, + IToolExecutor executor, + IConversationRuntime runtime, + ICheckpointStore checkpoints, + ISessionStore sessions, + CheckpointMutationCoordinator mutations, + IRuntimeHostContextAccessor host, + IPathService paths) +{ + private static readonly string[] ReadTools = ["workspace_search", "symbol_search", "dotnet_solution_inspect", "csharp_symbol_resolve", "csharp_find_references", "csharp_type_hierarchy", "csharp_diagnostics", "verify_workspace"]; + private readonly ConditionalWeakTable clients = new(); + + /// Validates the fixed workspace before opening the transport. + public void Validate(SharpClawMcpServerOptions options) + { + ArgumentNullException.ThrowIfNull(options); + if (!Directory.Exists(options.WorkspaceRoot)) throw new ArgumentException("MCP workspace must be an existing directory.", nameof(options)); + } + + /// Lists only the host-selected tools with their existing input contracts. + public async ValueTask ListAsync(SharpClawMcpServerOptions options, CancellationToken cancellationToken) + { + var allowed = Allowed(options); + var definitions = await registry.ListAsync(options.WorkspaceRoot, cancellationToken).ConfigureAwait(false); + return new ListToolsResult + { + Tools = definitions.Where(d => allowed.Contains(d.Name, StringComparer.Ordinal)).Select(d => new Tool + { + Name = d.Name, Description = d.Description, + InputSchema = JsonSerializer.Deserialize(d.InputSchemaJson ?? SearchSchema(d.Name)), + Annotations = new ToolAnnotations { ReadOnlyHint = !d.IsDestructive && d.ApprovalScope != ApprovalScope.ShellExecution, DestructiveHint = d.IsDestructive, OpenWorldHint = false }, + }).ToList(), + }; + } + + /// Executes under noninteractive permissions; hidden tools remain denied on direct calls. + public async ValueTask CallAsync(SharpClawMcpServerOptions options, RequestContext request, CancellationToken cancellationToken) + { + var toolName = request.Params?.Name; + var allowed = Allowed(options); + if (toolName is null || !allowed.Contains(toolName, StringComparer.Ordinal)) return Error("Tool is not exposed by this host."); + var state = clients.GetValue(request.Server, static _ => new ClientState()); + await state.Gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + using var scope = host.BeginScope(options.HostContext); + var root = paths.GetCanonicalFullPath(options.WorkspaceRoot); + if (options.PermissionMode != PermissionMode.ReadOnly && state.SessionId is null) + state.SessionId = (await runtime.CreateSessionAsync(root, options.PermissionMode, OutputFormat.Json, cancellationToken).ConfigureAwait(false)).Id; + var turnId = $"turn-{Guid.NewGuid():N}"; + var recorder = new TurnMutationAccumulator(); + var context = new ToolExecutionContext(state.SessionId ?? "", turnId, root, root, + options.PermissionMode, OutputFormat.Json, null, AllowedTools: allowed, IsInteractive: false, + SourceKind: PermissionRequestSourceKind.McpClient, SourceName: "sharpclaw-mcp-server", + PrimaryMode: options.PrimaryMode, MutationRecorder: recorder, ApprovalSettings: options.ApprovalSettings); + try + { + var args = request.Params?.Arguments; + var arguments = args is null ? "{}" : JsonSerializer.Serialize(args); + var envelope = await executor.ExecuteAsync(toolName, arguments, context, cancellationToken).ConfigureAwait(false); + var result = envelope.Result; + return new CallToolResult + { + IsError = !result.Succeeded, + Content = [new TextContentBlock { Text = result.Output ?? result.ErrorMessage ?? (result.Succeeded ? "Completed." : "Tool failed.") }], + StructuredContent = result.StructuredOutputJson is null ? null : JsonSerializer.Deserialize(result.StructuredOutputJson), + }; + } + finally + { + // Independent bounded finalization keeps actual edits recoverable even after cancellation/failure. + var operations = recorder.ToSnapshot(); + if (operations.Count > 0 && state.SessionId is not null) + { + using var finalization = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var session = await sessions.GetByIdAsync(root, state.SessionId, finalization.Token).ConfigureAwait(false) + ?? throw new InvalidOperationException("MCP mutation session disappeared."); + var checkpointId = $"checkpoint-{Guid.NewGuid():N}"; + await checkpoints.SaveAsync(root, new RuntimeCheckpoint(checkpointId, session.Id, turnId, DateTimeOffset.UtcNow, + $"MCP {toolName}", checkpointId, "mcp-tool", null), finalization.Token).ConfigureAwait(false); + await mutations.ApplyRecordedMutationsAsync(root, session, turnId, checkpointId, operations, finalization.Token).ConfigureAwait(false); + } + } + } + finally { state.Gate.Release(); } + } + + private static string[] Allowed(SharpClawMcpServerOptions options) + => options.AllowMutations && options.PermissionMode != PermissionMode.ReadOnly && options.PrimaryMode == PrimaryMode.Build ? [.. ReadTools, "csharp_rename_symbol"] : ReadTools; + + private static CallToolResult Error(string message) => new() { IsError = true, Content = [new TextContentBlock { Text = message }] }; + private static string SearchSchema(string name) => name == "symbol_search" + ? """{"type":"object","properties":{"query":{"type":"string"},"kind":{"type":"string"},"limit":{"type":"integer","minimum":1}},"required":["query"]}""" + : """{"type":"object","properties":{"query":{"type":"string"},"limit":{"type":"integer","minimum":1},"includeSymbols":{"type":"boolean"},"includeSemantic":{"type":"boolean"}},"required":["query"]}"""; + + private sealed class ClientState + { + public SemaphoreSlim Gate { get; } = new(1, 1); + public string? SessionId { get; set; } + } +} diff --git a/src/SharpClaw.Code.Mcp/Abstractions/ISharpClawMcpServer.cs b/src/SharpClaw.Code.Mcp/Abstractions/ISharpClawMcpServer.cs new file mode 100644 index 0000000..b7f274b --- /dev/null +++ b/src/SharpClaw.Code.Mcp/Abstractions/ISharpClawMcpServer.cs @@ -0,0 +1,10 @@ +using SharpClaw.Code.Mcp.Models; + +namespace SharpClaw.Code.Mcp.Abstractions; + +/// Hosts the inbound SharpClaw tool surface without starting agent or outbound MCP lifecycles. +public interface ISharpClawMcpServer +{ + /// Runs until cancellation or transport closure; stdout is reserved for stdio protocol traffic. + Task RunAsync(SharpClawMcpServerOptions options, CancellationToken cancellationToken); +} diff --git a/src/SharpClaw.Code.Mcp/Models/SharpClawMcpServerOptions.cs b/src/SharpClaw.Code.Mcp/Models/SharpClawMcpServerOptions.cs new file mode 100644 index 0000000..4595dff --- /dev/null +++ b/src/SharpClaw.Code.Mcp/Models/SharpClawMcpServerOptions.cs @@ -0,0 +1,38 @@ +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.Mcp.Models; + +/// Explicit host-selected permissions for inbound clients; tool arguments cannot override them. +/// Fixed workspace boundary. +/// Permission mode, read-only by default. +/// Whether permitted rename is advertised and callable. +/// Host-approved bounded elevation scopes. +/// Optional tenant-aware durable storage context. +/// Stdio or Streamable HTTP. +/// Explicit IP address or localhost; wildcard binding is rejected. +/// HTTP listen port. +/// Explicit authorization for non-loopback exposure. +/// Authentication secret for remote or elevated HTTP, supplied by the host. +/// Host-selected workflow restrictions forwarded to the permission policy. +public sealed record SharpClawMcpServerOptions( + string WorkspaceRoot, + PermissionMode PermissionMode = PermissionMode.ReadOnly, + bool AllowMutations = false, + ApprovalSettings? ApprovalSettings = null, + RuntimeHostContext? HostContext = null, + SharpClawMcpTransport Transport = SharpClawMcpTransport.Stdio, + string Host = "127.0.0.1", + int Port = 7346, + bool AllowRemote = false, + string? BearerToken = null, + PrimaryMode PrimaryMode = PrimaryMode.Build); + +/// The supported inbound transport choices. +public enum SharpClawMcpTransport +{ + /// Standard input/output with protocol-only stdout. + Stdio, + /// Stateful Streamable HTTP with bounded idle client lifecycle. + Http, +} diff --git a/src/SharpClaw.Code.Memory/Abstractions/IDotNetWorkspaceEvaluationAuthorization.cs b/src/SharpClaw.Code.Memory/Abstractions/IDotNetWorkspaceEvaluationAuthorization.cs new file mode 100644 index 0000000..72ebd3a --- /dev/null +++ b/src/SharpClaw.Code.Memory/Abstractions/IDotNetWorkspaceEvaluationAuthorization.cs @@ -0,0 +1,10 @@ +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.Memory.Abstractions; + +/// Authorizes project-controlled design-time execution. Hosts must implement this through their permission policy. +public interface IDotNetWorkspaceEvaluationAuthorization +{ + /// Authorizes a fresh load/reload. Returning false must cause no project execution. + Task AuthorizeAsync(DotNetWorkspaceRequest request, CancellationToken cancellationToken); +} diff --git a/src/SharpClaw.Code.Memory/Abstractions/IDotNetWorkspaceSemanticService.cs b/src/SharpClaw.Code.Memory/Abstractions/IDotNetWorkspaceSemanticService.cs new file mode 100644 index 0000000..bda596f --- /dev/null +++ b/src/SharpClaw.Code.Memory/Abstractions/IDotNetWorkspaceSemanticService.cs @@ -0,0 +1,21 @@ +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.Memory.Abstractions; + +/// Provides on-demand compiler semantics alongside the existing persisted lexical index. +public interface IDotNetWorkspaceSemanticService +{ + /// Inspects an approved solution/project; null authorization permits valid cached queries only. + Task InspectSolutionAsync(DotNetWorkspaceRequest request, IDotNetWorkspaceEvaluationAuthorization? authorization, CancellationToken cancellationToken); + + /// Resolves exact source symbols, returning candidates instead of guessing. + Task ResolveSymbolAsync(DotNetWorkspaceRequest request, CSharpSymbolQuery query, IDotNetWorkspaceEvaluationAuthorization? authorization, CancellationToken cancellationToken); + /// Finds compiler references to an exactly selected symbol. + Task FindReferencesAsync(DotNetWorkspaceRequest request, CSharpToolArguments arguments, IDotNetWorkspaceEvaluationAuthorization? authorization, CancellationToken cancellationToken); + /// Finds the selected type's semantic relationships. + Task GetTypeHierarchyAsync(DotNetWorkspaceRequest request, CSharpToolArguments arguments, IDotNetWorkspaceEvaluationAuthorization? authorization, CancellationToken cancellationToken); + /// Gets compiler diagnostics with bounded paging and filters. + Task GetDiagnosticsAsync(DotNetWorkspaceRequest request, CSharpToolArguments arguments, IDotNetWorkspaceEvaluationAuthorization? authorization, CancellationToken cancellationToken); + /// Calculates a conflict-checked rename without applying changes to disk. + Task PlanRenameAsync(DotNetWorkspaceRequest request, CSharpToolArguments arguments, IDotNetWorkspaceEvaluationAuthorization? authorization, CancellationToken cancellationToken); +} diff --git a/src/SharpClaw.Code.Memory/Abstractions/IDotNetWorkspaceTargetResolver.cs b/src/SharpClaw.Code.Memory/Abstractions/IDotNetWorkspaceTargetResolver.cs new file mode 100644 index 0000000..7a6cfb0 --- /dev/null +++ b/src/SharpClaw.Code.Memory/Abstractions/IDotNetWorkspaceTargetResolver.cs @@ -0,0 +1,10 @@ +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.Memory.Abstractions; + +/// Discovers a contained .NET target without executing project code or restoring packages. +public interface IDotNetWorkspaceTargetResolver +{ + /// Resolves an explicit target or reports ambiguous/unsupported workspaces. + Task ResolveAsync(DotNetWorkspaceRequest request, CancellationToken cancellationToken); +} diff --git a/src/SharpClaw.Code.Memory/MemoryServiceCollectionExtensions.cs b/src/SharpClaw.Code.Memory/MemoryServiceCollectionExtensions.cs index b1c71ed..7c1a66a 100644 --- a/src/SharpClaw.Code.Memory/MemoryServiceCollectionExtensions.cs +++ b/src/SharpClaw.Code.Memory/MemoryServiceCollectionExtensions.cs @@ -21,6 +21,8 @@ public static IServiceCollection AddSharpClawMemory(this IServiceCollection serv services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/SharpClaw.Code.Memory/Services/DotNetSemanticWorkspaceCache.cs b/src/SharpClaw.Code.Memory/Services/DotNetSemanticWorkspaceCache.cs new file mode 100644 index 0000000..754389b --- /dev/null +++ b/src/SharpClaw.Code.Memory/Services/DotNetSemanticWorkspaceCache.cs @@ -0,0 +1,279 @@ +using System.Collections.Concurrent; +using System.Security.Cryptography; +using System.Xml; +using System.Xml.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.MSBuild; +using Microsoft.CodeAnalysis.Text; +using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Memory.Abstractions; +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.Memory.Services; + +/// +/// Owns a bounded semantic cache. One async gate protects loading, queries, refresh, and eviction, +/// so a workspace can never be disposed while in use. Cached source-only refresh does not run generators. +/// +internal sealed class DotNetSemanticWorkspaceCache(IFileSystem fileSystem, IPathService pathService, IDotNetWorkspaceTargetResolver targetResolver) : IDisposable +{ + private const int Capacity = 4; + private readonly SemaphoreSlim gate = new(1, 1); + private readonly Dictionary entries = new(OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); + private bool disposed; + + internal async Task AcquireAsync(DotNetWorkspaceRequest request, IDotNetWorkspaceEvaluationAuthorization? authorization, CancellationToken cancellationToken) + { + await gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + ObjectDisposedException.ThrowIf(disposed, this); + var target = await targetResolver.ResolveAsync(request, cancellationToken).ConfigureAwait(false); + if (target.Status != DotNetWorkspaceStatus.Ready) + { + return new Lease(target, null, gate); + } + + var key = string.Join('\0', target.WorkspaceRoot, target.TargetPath, request.Configuration, request.TargetFramework); + entries.TryGetValue(key, out var snapshot); + var fingerprint = await FingerprintAsync(target, snapshot, cancellationToken).ConfigureAwait(false); + if (snapshot is not null && (!snapshot.Reusable || !string.Equals(snapshot.Fingerprint, fingerprint, StringComparison.Ordinal))) + { + entries.Remove(key); + snapshot.Workspace.Dispose(); + snapshot = null; + } + + if (snapshot is null) + { + if (authorization is null || !await authorization.AuthorizeAsync(request with { WorkspaceRoot = target.WorkspaceRoot, Target = target.TargetPath }, cancellationToken).ConfigureAwait(false)) + { + return new Lease(target with { Status = DotNetWorkspaceStatus.PermissionDenied, Message = "MSBuild project evaluation requires execution authorization; no valid approved snapshot is cached." }, null, gate); + } + + if (entries.Count == Capacity) + { + var oldest = entries.MinBy(pair => pair.Value.LastUsedAtUtc); + entries.Remove(oldest.Key); + oldest.Value.Workspace.Dispose(); + } + + var properties = new Dictionary { ["Configuration"] = request.Configuration, ["RestoreDuringBuild"] = "false" }; + if (!string.IsNullOrWhiteSpace(request.TargetFramework)) properties["TargetFramework"] = request.TargetFramework; + var workspace = MSBuildWorkspace.Create(properties); + var messages = new ConcurrentQueue(); + using var failedHandler = workspace.RegisterWorkspaceFailedHandler(args => messages.Enqueue(args.Diagnostic.ToString())); + try + { + // Roslyn 5.9's bundled out-of-process build host resolves the workspace SDK and owns Locator registration. + var solution = target.TargetPath!.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase) + ? (await workspace.OpenProjectAsync(target.TargetPath, cancellationToken: cancellationToken).ConfigureAwait(false)).Solution + : await workspace.OpenSolutionAsync(target.TargetPath, cancellationToken: cancellationToken).ConfigureAwait(false); + foreach (var project in solution.Projects.ToArray()) + { + if (project.FilePath is null || !DotNetWorkspaceTargetResolver.IsWithin(target.WorkspaceRoot, pathService.GetCanonicalFullPath(project.FilePath))) + { + throw new InvalidOperationException("A referenced project is outside the workspace boundary."); + } + using var reader = XmlReader.Create(new StringReader(await fileSystem.ReadAllTextIfExistsAsync(project.FilePath, cancellationToken).ConfigureAwait(false) ?? ""), new XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null }); + var projectXml = XDocument.Load(reader); + if (projectXml.Root?.Attribute("Sdk") is not null && !fileSystem.FileExists(Path.Combine(Path.GetDirectoryName(project.FilePath)!, "obj", "project.assets.json"))) + { + messages.Enqueue("Restore required: project.assets.json is missing for " + project.FilePath); + } + // Compiler queries never execute project-supplied analyzers or source generators. + solution = solution.WithProjectAnalyzerReferences(project.Id, []); + } + + var status = messages.Count == 0 ? DotNetWorkspaceStatus.Ready : ClassifyFailure(string.Join('\n', messages), partial: true); + if (!solution.Projects.Any() || status is DotNetWorkspaceStatus.RestoreRequired or DotNetWorkspaceStatus.SdkUnavailable) + { + workspace.Dispose(); + return new Lease(target with { Status = status == DotNetWorkspaceStatus.Ready ? DotNetWorkspaceStatus.LoadFailed : status, Message = string.Join('\n', messages) }, null, gate); + } + + snapshot = new Snapshot(workspace, solution, status, messages.ToArray(), fingerprint); + snapshot.Fingerprint = await FingerprintAsync(target, snapshot, cancellationToken).ConfigureAwait(false); + entries.Add(key, snapshot); + } + catch (OperationCanceledException) + { + workspace.Dispose(); + throw; + } + catch (Exception exception) when (exception is IOException or InvalidOperationException or ArgumentException or XmlException || exception.GetType().Name == "RemoteInvocationException") + { + workspace.Dispose(); + return new Lease(target with { Status = ClassifyFailure(exception.Message), Message = exception.Message }, null, gate); + } + } + + try + { + await RefreshSourcesAsync(target.WorkspaceRoot, snapshot, cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) when (exception is IOException or InvalidOperationException) + { + entries.Remove(key); + snapshot.Workspace.Dispose(); + return new Lease(target with { Status = DotNetWorkspaceStatus.LoadFailed, Message = exception.Message }, null, gate); + } + snapshot.LastUsedAtUtc = DateTimeOffset.UtcNow; + return new Lease(target with { Status = snapshot.Status }, snapshot, gate); + } + catch + { + gate.Release(); + throw; + } + } + + private async Task RefreshSourcesAsync(string root, Snapshot snapshot, CancellationToken cancellationToken) + { + foreach (var document in snapshot.Solution.Projects.SelectMany(project => project.Documents).ToArray()) + { + if (document.FilePath is null) continue; + var path = pathService.GetCanonicalFullPath(document.FilePath); + // Approved MSBuild loads can include SDK/package bootstrap sources outside the workspace. + // Never reread these in an unapproved cached query; metadata stamps invalidate and require a fresh load. + if (!DotNetWorkspaceTargetResolver.IsWithin(root, path)) continue; + var content = await fileSystem.ReadAllTextIfExistsAsync(path, cancellationToken).ConfigureAwait(false); + if (content is null) throw new IOException($"Source document '{DotNetWorkspaceTargetResolver.Relative(root, path)}' disappeared; reload is required."); + var previous = await document.GetTextAsync(cancellationToken).ConfigureAwait(false); + if (!string.Equals(previous.ToString(), content, StringComparison.Ordinal)) + { + snapshot.Solution = snapshot.Solution.WithDocumentText(document.Id, SourceText.From(content, previous.Encoding ?? Encoding.UTF8)); + } + } + } + + private async Task FingerprintAsync(DotNetWorkspaceTarget target, Snapshot? snapshot, CancellationToken cancellationToken) + { + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + var inputs = EnumerateInputs(target.WorkspaceRoot).ToHashSet(StringComparer.Ordinal); + for (var directory = new DirectoryInfo(target.WorkspaceRoot); directory is not null; directory = directory.Parent) + { + foreach (var name in new[] { "Directory.Build.props", "Directory.Build.targets", "Directory.Packages.props", "global.json", "NuGet.Config", "nuget.config" }) + { + var path = Path.Combine(directory.FullName, name); + if (fileSystem.FileExists(path)) inputs.Add(path); + } + } + if (snapshot is not null) + { + foreach (var project in snapshot.Solution.Projects) + { + var assets = Path.Combine(Path.GetDirectoryName(project.FilePath!)!, "obj", "project.assets.json"); + if (fileSystem.FileExists(assets)) inputs.Add(assets); + } + } + if (snapshot is not null) + { + var externalInputs = snapshot.Solution.Projects.SelectMany(project => project.Documents.Select(document => document.FilePath) + .Concat(project.MetadataReferences.OfType().Select(reference => reference.FilePath))) + .OfType().Where(path => !DotNetWorkspaceTargetResolver.IsWithin(target.WorkspaceRoot, pathService.GetCanonicalFullPath(path))).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal); + foreach (var path in externalInputs) + { + var info = new FileInfo(path); + hash.AppendData(Encoding.UTF8.GetBytes(path + "\0" + (info.Exists ? info.Length + ":" + info.LastWriteTimeUtc.Ticks : "missing") + "\0")); + } + } + // Explicit imported build files can live outside the workspace. Track literal imports recursively; + // property-driven/wildcard imports cannot be resolved safely without evaluation, so never reuse that snapshot. + var importQueue = new Queue(inputs.Where(input => Path.GetExtension(input).ToLowerInvariant() is ".csproj" or ".props" or ".targets")); + var visitedImports = new HashSet(StringComparer.Ordinal); + while (importQueue.TryDequeue(out var buildFile)) + { + if (!visitedImports.Add(buildFile)) continue; + if (visitedImports.Count > 1024) { if (snapshot is not null) snapshot.Reusable = false; break; } + var buildText = await fileSystem.ReadAllTextIfExistsAsync(buildFile, cancellationToken).ConfigureAwait(false); + if (buildText is null) continue; + using var reader = XmlReader.Create(new StringReader(buildText), new XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null }); + XDocument xml; + try { xml = XDocument.Load(reader); } + catch (XmlException) { if (snapshot is not null) snapshot.Reusable = false; continue; } + foreach (var import in xml.Descendants().Where(element => element.Name.LocalName == "Import")) + { + var imported = ((string?)import.Attribute("Project"))?.Replace("$(MSBuildThisFileDirectory)", Path.GetDirectoryName(buildFile) + Path.DirectorySeparatorChar, StringComparison.Ordinal); + if (string.IsNullOrWhiteSpace(imported)) continue; + if (imported.Contains("$(", StringComparison.Ordinal) || imported.Contains('*') || imported.Contains('?')) + { if (snapshot is not null) snapshot.Reusable = false; continue; } + var importPath = pathService.GetCanonicalFullPath(Path.Combine(Path.GetDirectoryName(buildFile)!, imported.Replace('\\', Path.DirectorySeparatorChar))); + if (inputs.Add(importPath)) importQueue.Enqueue(importPath); + } + } + foreach (var input in inputs.Order(StringComparer.Ordinal)) + { + cancellationToken.ThrowIfCancellationRequested(); + hash.AppendData(Encoding.UTF8.GetBytes(input + "\0")); + // A changed compile-item path needs MSBuild reevaluation; content-only changes are refreshed without evaluation. + if (!input.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)) + { + hash.AppendData(Encoding.UTF8.GetBytes(await fileSystem.ReadAllTextIfExistsAsync(input, cancellationToken).ConfigureAwait(false) ?? "")); + } + hash.AppendData([0]); + } + return Convert.ToHexString(hash.GetHashAndReset()); + } + + private IEnumerable EnumerateInputs(string root) + { + var pending = new Stack(); + var visited = new HashSet(OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); + pending.Push(root); + while (pending.TryPop(out var current)) + { + current = pathService.GetCanonicalFullPath(current); + if (!DotNetWorkspaceTargetResolver.IsWithin(root, current) || !visited.Add(current)) continue; + foreach (var directory in fileSystem.EnumerateDirectories(current)) + { + if (Path.GetFileName(directory).ToLowerInvariant() is not ("bin" or "obj" or ".git" or ".sharpclaw" or "node_modules")) pending.Push(directory); + } + foreach (var file in fileSystem.EnumerateFiles(current, "*")) + { + if (Path.GetExtension(file).ToLowerInvariant() is ".cs" or ".csproj" or ".sln" or ".slnx" or ".props" or ".targets" or ".editorconfig" + || Path.GetFileName(file).ToLowerInvariant() is "global.json" or "packages.lock.json" or "nuget.config") yield return file; + } + } + } + + private static DotNetWorkspaceStatus ClassifyFailure(string message, bool partial = false) + { + if (message.Contains("project.assets.json", StringComparison.OrdinalIgnoreCase) || message.Contains("NETSDK1004", StringComparison.Ordinal)) return DotNetWorkspaceStatus.RestoreRequired; + if (message.Contains("SDK", StringComparison.OrdinalIgnoreCase) && (message.Contains("not found", StringComparison.OrdinalIgnoreCase) || message.Contains("could not", StringComparison.OrdinalIgnoreCase) || message.Contains("not installed", StringComparison.OrdinalIgnoreCase))) return DotNetWorkspaceStatus.SdkUnavailable; + return partial ? DotNetWorkspaceStatus.Partial : DotNetWorkspaceStatus.LoadFailed; + } + + public void Dispose() + { + gate.Wait(); + try + { + if (disposed) return; + disposed = true; + foreach (var snapshot in entries.Values) snapshot.Workspace.Dispose(); + entries.Clear(); + } + finally { gate.Release(); } + } + + internal sealed class Snapshot(MSBuildWorkspace workspace, Solution solution, DotNetWorkspaceStatus status, string[] messages, string fingerprint) + { + internal MSBuildWorkspace Workspace { get; } = workspace; + internal Solution Solution { get; set; } = solution; + internal DotNetWorkspaceStatus Status { get; } = status; + internal string[] Messages { get; } = messages; + internal string Fingerprint { get; set; } = fingerprint; + internal bool Reusable { get; set; } = true; + internal DateTimeOffset LastUsedAtUtc { get; set; } = DateTimeOffset.UtcNow; + } + + internal sealed class Lease(DotNetWorkspaceTarget target, Snapshot? snapshot, SemaphoreSlim gate) : IDisposable + { + internal DotNetWorkspaceTarget Target { get; } = target; + internal Snapshot? Snapshot { get; } = snapshot; + private bool disposed; + public void Dispose() { if (!disposed) { disposed = true; gate.Release(); } } + } +} diff --git a/src/SharpClaw.Code.Memory/Services/DotNetWorkspaceSemanticQueries.cs b/src/SharpClaw.Code.Memory/Services/DotNetWorkspaceSemanticQueries.cs new file mode 100644 index 0000000..4dbd061 --- /dev/null +++ b/src/SharpClaw.Code.Memory/Services/DotNetWorkspaceSemanticQueries.cs @@ -0,0 +1,91 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.FindSymbols; +using SharpClaw.Code.Memory.Abstractions; +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.Memory.Services; + +public sealed partial class DotNetWorkspaceSemanticService +{ + /// + public async Task FindReferencesAsync(DotNetWorkspaceRequest request, CSharpToolArguments arguments, IDotNetWorkspaceEvaluationAuthorization? authorization, CancellationToken cancellationToken) + { + using var lease = await cache.AcquireAsync(request, authorization, cancellationToken).ConfigureAwait(false); + var (resolution, candidates) = await ResolveAsync(lease, arguments.Query(), cancellationToken).ConfigureAwait(false); + if (resolution.Symbol is null) return new(resolution, [], 0, arguments.Offset, false); + var groups = await SymbolFinder.FindReferencesAsync(candidates[0].Symbol, lease.Snapshot!.Solution, cancellationToken).ConfigureAwait(false); + var references = groups.SelectMany(group => group.Locations) + .Where(reference => reference.Document.FilePath is not null && DotNetWorkspaceTargetResolver.IsWithin(lease.Target.WorkspaceRoot, pathService.GetCanonicalFullPath(reference.Document.FilePath))) + .Select(reference => new CSharpReference(Relative(lease.Target.WorkspaceRoot, reference.Document.Project.FilePath!), DescribeLocation(reference.Location, lease.Target.WorkspaceRoot), reference.IsImplicit ? "implicit" : "explicit")) + .Distinct().OrderBy(reference => reference.Location.Path, StringComparer.Ordinal).ThenBy(reference => reference.Location.Line).ThenBy(reference => reference.Location.Column).ToArray(); + var (offset, limit) = Page(arguments); + return new(resolution, references.Skip(offset).Take(limit).ToArray(), references.Length, offset, offset + limit < references.Length); + } + + /// + public async Task GetTypeHierarchyAsync(DotNetWorkspaceRequest request, CSharpToolArguments arguments, IDotNetWorkspaceEvaluationAuthorization? authorization, CancellationToken cancellationToken) + { + using var lease = await cache.AcquireAsync(request, authorization, cancellationToken).ConfigureAwait(false); + var (resolution, candidates) = await ResolveAsync(lease, arguments.Query(), cancellationToken).ConfigureAwait(false); + if (resolution.Symbol is null) return new(resolution, null, [], [], [], false); + if (candidates[0].Symbol is not INamedTypeSymbol type) throw new ArgumentException("Hierarchy requires a class, interface, struct or record."); + var solution = lease.Snapshot!.Solution; + var project = candidates[0].Project; + var root = lease.Target.WorkspaceRoot; + var derived = type.TypeKind == TypeKind.Interface + ? await SymbolFinder.FindDerivedInterfacesAsync(type, solution, cancellationToken: cancellationToken).ConfigureAwait(false) + : await SymbolFinder.FindDerivedClassesAsync(type, solution, cancellationToken: cancellationToken).ConfigureAwait(false); + var implementations = type.TypeKind == TypeKind.Interface + ? await SymbolFinder.FindImplementationsAsync(type, solution, cancellationToken: cancellationToken).ConfigureAwait(false) : []; + CSharpSymbolDescriptor DescribeType(ISymbol symbol) => Describe(symbol, symbol.Locations.Where(location => location.SourceTree is not null).Select(location => solution.GetDocument(location.SourceTree!)?.Project).FirstOrDefault(item => item is not null) ?? project, root); + var (_, limit) = Page(arguments); + var derivedArray = derived.Select(DescribeType).OrderBy(item => item.Signature, StringComparer.Ordinal).ToArray(); + var implementationArray = implementations.Select(DescribeType).Distinct().OrderBy(item => item.Signature, StringComparer.Ordinal).ToArray(); + return new(resolution, type.BaseType is null ? null : DescribeType(type.BaseType), type.AllInterfaces.Select(DescribeType).Take(limit).ToArray(), derivedArray.Take(limit).ToArray(), implementationArray.Take(limit).ToArray(), type.AllInterfaces.Length > limit || derivedArray.Length > limit || implementationArray.Length > limit); + } + + /// + public async Task GetDiagnosticsAsync(DotNetWorkspaceRequest request, CSharpToolArguments arguments, IDotNetWorkspaceEvaluationAuthorization? authorization, CancellationToken cancellationToken) + { + using var lease = await cache.AcquireAsync(request, authorization, cancellationToken).ConfigureAwait(false); + var (offset, limit) = Page(arguments); + if (lease.Snapshot is not { } snapshot) return new(lease.Target.Status, [], 0, offset, false, lease.Target.Message is null ? [] : [lease.Target.Message]); + var diagnostics = new List(); + foreach (var project in snapshot.Solution.Projects) + { + var relativeProject = Relative(lease.Target.WorkspaceRoot, project.FilePath!); + if (arguments.Project is not null && arguments.Project != project.Name && arguments.Project != relativeProject) continue; + var compilation = await project.GetCompilationAsync(cancellationToken).ConfigureAwait(false); + if (compilation is null) continue; + foreach (var diagnostic in compilation.GetDiagnostics(cancellationToken)) + { + if (arguments.Severity is not null && !string.Equals(arguments.Severity, diagnostic.Severity.ToString(), StringComparison.OrdinalIgnoreCase)) continue; + if (arguments.Code is not null && !string.Equals(arguments.Code, diagnostic.Id, StringComparison.OrdinalIgnoreCase)) continue; + CSharpSymbolLocation? location = null; + if (diagnostic.Location.IsInSource && diagnostic.Location.SourceTree is not null) + { + if (!DotNetWorkspaceTargetResolver.IsWithin(lease.Target.WorkspaceRoot, pathService.GetCanonicalFullPath(diagnostic.Location.SourceTree.FilePath))) continue; + location = DescribeLocation(diagnostic.Location, lease.Target.WorkspaceRoot); + } + if (arguments.Path is not null && arguments.Path.Replace('\\', '/') != location?.Path) continue; + diagnostics.Add(new(relativeProject, project.OutputFilePath is null ? null : Path.GetFileName(Path.GetDirectoryName(project.OutputFilePath)), diagnostic.Id, diagnostic.Severity.ToString().ToLowerInvariant(), diagnostic.GetMessage(), location)); + } + } + var ordered = diagnostics.Distinct().OrderBy(item => item.ProjectPath, StringComparer.Ordinal).ThenBy(item => item.Location?.Path, StringComparer.Ordinal).ThenBy(item => item.Location?.Line).ThenBy(item => item.Code, StringComparer.Ordinal).ToArray(); + return new(snapshot.Status, ordered.Skip(offset).Take(limit).ToArray(), ordered.Length, offset, offset + limit < ordered.Length, snapshot.Messages); + } + + private async Task<(CSharpSymbolResolutionResult Resolution, List<(ISymbol Symbol, Project Project)> Candidates)> ResolveAsync(DotNetSemanticWorkspaceCache.Lease lease, CSharpSymbolQuery query, CancellationToken cancellationToken) + { + if (lease.Snapshot is null) return (new(CSharpSymbolResolutionStatus.WorkspaceUnavailable, lease.Target.Status, [], Messages: lease.Target.Message is null ? [] : [lease.Target.Message]), []); + var candidates = await ResolveCandidatesAsync(lease.Snapshot.Solution, lease.Target.WorkspaceRoot, query, cancellationToken).ConfigureAwait(false); + var descriptions = candidates.Select(item => Describe(item.Symbol, item.Project, lease.Target.WorkspaceRoot)).ToArray(); + return (new(descriptions.Length switch { 0 => CSharpSymbolResolutionStatus.NotFound, 1 => CSharpSymbolResolutionStatus.Resolved, _ => CSharpSymbolResolutionStatus.Ambiguous }, lease.Target.Status, descriptions, descriptions.Length == 1 ? descriptions[0] : null), candidates); + } + + private static (int Offset, int Limit) Page(CSharpToolArguments arguments) + { + if (arguments.Offset < 0 || arguments.Limit is < 1 or > 200) throw new ArgumentException("offset must be nonnegative and limit must be between 1 and 200."); + return (arguments.Offset, arguments.Limit); + } +} diff --git a/src/SharpClaw.Code.Memory/Services/DotNetWorkspaceSemanticRename.cs b/src/SharpClaw.Code.Memory/Services/DotNetWorkspaceSemanticRename.cs new file mode 100644 index 0000000..23c5c06 --- /dev/null +++ b/src/SharpClaw.Code.Memory/Services/DotNetWorkspaceSemanticRename.cs @@ -0,0 +1,52 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Rename; +using SharpClaw.Code.Memory.Abstractions; +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.Memory.Services; + +public sealed partial class DotNetWorkspaceSemanticService +{ + /// + public async Task PlanRenameAsync(DotNetWorkspaceRequest request, CSharpToolArguments arguments, IDotNetWorkspaceEvaluationAuthorization? authorization, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(arguments.NewName); + if (!SyntaxFacts.IsValidIdentifier(arguments.NewName) || SyntaxFacts.GetKeywordKind(arguments.NewName) != SyntaxKind.None) throw new ArgumentException("newName must be a valid non-keyword C# identifier."); + using var lease = await cache.AcquireAsync(request, authorization, cancellationToken).ConfigureAwait(false); + var (resolution, candidates) = await ResolveAsync(lease, arguments.Query(), cancellationToken).ConfigureAwait(false); + if (resolution.Symbol is null) return new(resolution, arguments.NewName, [], ["Rename requires one exact source symbol."]); + var original = lease.Snapshot!.Solution; + var renamed = await Renamer.RenameSymbolAsync(original, candidates[0].Symbol, new SymbolRenameOptions(false, false, false, false), arguments.NewName, cancellationToken).ConfigureAwait(false); + var changes = new Dictionary(OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); + foreach (var projectChange in renamed.GetChanges(original).GetProjectChanges()) + { + foreach (var id in projectChange.GetChangedDocuments()) + { + var previous = original.GetDocument(id)!; + var updated = renamed.GetDocument(id)!; + if (previous.FilePath is null) return new(resolution, arguments.NewName, [], ["A generated document has no writable physical path."]); + var path = pathService.GetCanonicalFullPath(previous.FilePath); + if (!DotNetWorkspaceTargetResolver.IsWithin(lease.Target.WorkspaceRoot, path)) return new(resolution, arguments.NewName, [], ["A changed document escapes the workspace boundary."]); + var before = (await previous.GetTextAsync(cancellationToken).ConfigureAwait(false)).ToString(); + if (path.EndsWith(".g.cs", StringComparison.OrdinalIgnoreCase) || path.EndsWith(".generated.cs", StringComparison.OrdinalIgnoreCase) || before.TrimStart().StartsWith("// item.Severity == DiagnosticSeverity.Error).Select(item => item.Id + ":" + item.GetMessage()).ToHashSet(StringComparer.Ordinal) ?? []; + if (newCompilation?.GetDiagnostics(cancellationToken).Any(item => item.Severity == DiagnosticSeverity.Error && !existing.Contains(item.Id + ":" + item.GetMessage())) == true) return new(resolution, arguments.NewName, [], ["Rename would introduce compiler errors; no changes were applied."]); + } + return new(resolution, arguments.NewName, changes.Values.OrderBy(change => change.Path, StringComparer.Ordinal).ToArray(), []); + } +} diff --git a/src/SharpClaw.Code.Memory/Services/DotNetWorkspaceSemanticService.cs b/src/SharpClaw.Code.Memory/Services/DotNetWorkspaceSemanticService.cs new file mode 100644 index 0000000..f5b772e --- /dev/null +++ b/src/SharpClaw.Code.Memory/Services/DotNetWorkspaceSemanticService.cs @@ -0,0 +1,151 @@ +using System.Text.Json; +using System.Xml; +using System.Xml.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.FindSymbols; +using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Memory.Abstractions; +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.Memory.Services; + +/// Loads approved compiler workspaces lazily; no semantic load is involved in ordinary indexed search. +public sealed partial class DotNetWorkspaceSemanticService : IDotNetWorkspaceSemanticService, IDisposable +{ + private readonly IFileSystem fileSystem; + private readonly IPathService pathService; + private readonly DotNetSemanticWorkspaceCache cache; + + /// Creates a semantic service with a bounded, lifecycle-owned cache. + public DotNetWorkspaceSemanticService(IFileSystem fileSystem, IPathService pathService, IDotNetWorkspaceTargetResolver targetResolver) + { + this.fileSystem = fileSystem; + this.pathService = pathService; + cache = new DotNetSemanticWorkspaceCache(fileSystem, pathService, targetResolver); + } + + /// + public async Task InspectSolutionAsync(DotNetWorkspaceRequest request, IDotNetWorkspaceEvaluationAuthorization? authorization, CancellationToken cancellationToken) + { + using var lease = await cache.AcquireAsync(request, authorization, cancellationToken).ConfigureAwait(false); + var target = lease.Target; + if (lease.Snapshot is not { } snapshot) + { + return new DotNetSolutionSummary(target.Status, target.WorkspaceRoot, target.TargetPath is null ? null : Relative(target.WorkspaceRoot, target.TargetPath), [], [], target.Message is null ? [] : [target.Message]); + } + + var projects = new List(); + foreach (var project in snapshot.Solution.Projects.OrderBy(project => project.FilePath, StringComparer.Ordinal)) + { + var projectPath = Relative(target.WorkspaceRoot, project.FilePath!); + var xml = await ReadProjectXmlAsync(project.FilePath!, cancellationToken).ConfigureAwait(false); + var packages = xml.Descendants().Where(element => element.Name.LocalName == "PackageReference") + .Where(element => element.Attribute("Include") is not null) + .Select(element => new DotNetPackageReference(element.Attribute("Include")!.Value, element.Attribute("Version")?.Value ?? element.Elements().FirstOrDefault(child => child.Name.LocalName == "Version")?.Value)).ToArray(); + var frameworks = xml.Descendants().Where(element => element.Name.LocalName is "TargetFramework" or "TargetFrameworks") + .SelectMany(element => element.Value.Split(';', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)).Distinct().ToArray(); + var assetsPath = Path.Combine(Path.GetDirectoryName(project.FilePath!)!, "obj", "project.assets.json"); + if (await fileSystem.ReadAllTextIfExistsAsync(assetsPath, cancellationToken).ConfigureAwait(false) is { } assetsText) + { + using var assets = JsonDocument.Parse(assetsText); + if (assets.RootElement.TryGetProperty("project", out var projectNode) && projectNode.TryGetProperty("frameworks", out var frameworkNodes)) frameworks = frameworkNodes.EnumerateObject().Select(property => property.Name).ToArray(); + if (assets.RootElement.TryGetProperty("libraries", out var libraries)) + { + packages = packages.Select(package => package with + { + Version = libraries.EnumerateObject().Where(property => property.Name.StartsWith(package.Name + "/", StringComparison.OrdinalIgnoreCase)).Select(property => property.Name[(package.Name.Length + 1)..]).FirstOrDefault() ?? package.Version + }).ToArray(); + } + } + + var references = project.ProjectReferences.Select(reference => snapshot.Solution.GetProject(reference.ProjectId)?.FilePath) + .OfType().Select(path => Relative(target.WorkspaceRoot, path)).Distinct().Order(StringComparer.Ordinal).ToArray(); + var evaluatedFramework = project.OutputFilePath is null ? request.TargetFramework : Path.GetFileName(Path.GetDirectoryName(project.OutputFilePath)); + var isTestProject = xml.Descendants().Any(element => element.Name.LocalName == "IsTestProject" && string.Equals(element.Value, "true", StringComparison.OrdinalIgnoreCase)) + || packages.Any(package => package.Name is "Microsoft.NET.Test.Sdk" or "xunit" or "NUnit" or "MSTest.TestFramework") + || project.Name.EndsWith(".Tests", StringComparison.OrdinalIgnoreCase); + projects.Add(new DotNetProjectSummary(project.Name, projectPath, project.Language, frameworks, evaluatedFramework, isTestProject, project.DocumentIds.Count, references, packages, project.Documents.Where(document => document.FilePath is not null).Select(document => Relative(target.WorkspaceRoot, document.FilePath!)).Distinct().ToArray(), project.OutputFilePath is null ? null : Relative(target.WorkspaceRoot, project.OutputFilePath))); + } + + return new DotNetSolutionSummary(snapshot.Status, target.WorkspaceRoot, Relative(target.WorkspaceRoot, target.TargetPath!), projects.ToArray(), + projects.SelectMany(project => project.ProjectReferences.Select(reference => new DotNetProjectReference(project.Path, reference))).ToArray(), + [.. snapshot.Messages, "Project-supplied analyzers and source generators are excluded from compiler queries."]); + } + + /// + public async Task ResolveSymbolAsync(DotNetWorkspaceRequest request, CSharpSymbolQuery query, IDotNetWorkspaceEvaluationAuthorization? authorization, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(query.Name); + using var lease = await cache.AcquireAsync(request, authorization, cancellationToken).ConfigureAwait(false); + if (lease.Snapshot is not { } snapshot) + { + return new CSharpSymbolResolutionResult(CSharpSymbolResolutionStatus.WorkspaceUnavailable, lease.Target.Status, [], Messages: lease.Target.Message is null ? [] : [lease.Target.Message]); + } + + var resolved = await ResolveCandidatesAsync(snapshot.Solution, lease.Target.WorkspaceRoot, query, cancellationToken).ConfigureAwait(false); + var candidates = resolved.Select(candidate => Describe(candidate.Symbol, candidate.Project, lease.Target.WorkspaceRoot)).ToArray(); + return new CSharpSymbolResolutionResult(candidates.Length switch + { + 0 => CSharpSymbolResolutionStatus.NotFound, + 1 => CSharpSymbolResolutionStatus.Resolved, + _ => CSharpSymbolResolutionStatus.Ambiguous + }, snapshot.Status, candidates, candidates.Length == 1 ? candidates[0] : null, snapshot.Messages); + } + + private async Task ReadProjectXmlAsync(string path, CancellationToken cancellationToken) + { + var text = await fileSystem.ReadAllTextIfExistsAsync(path, cancellationToken).ConfigureAwait(false) ?? throw new IOException("Project file disappeared."); + using var reader = XmlReader.Create(new StringReader(text), new XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null }); + return XDocument.Load(reader); + } + + internal async Task> ResolveCandidatesAsync(Solution solution, string root, CSharpSymbolQuery query, CancellationToken cancellationToken) + { + string? selectedPath = null; + if (!string.IsNullOrWhiteSpace(query.Path)) + { + selectedPath = pathService.GetCanonicalFullPath(Path.IsPathRooted(query.Path) ? query.Path : Path.Combine(root, query.Path)); + if (!DotNetWorkspaceTargetResolver.IsWithin(root, selectedPath)) throw new ArgumentException("Symbol path escapes the workspace boundary.", nameof(query)); + } + var result = new List<(ISymbol, Project)>(); + foreach (var project in solution.Projects.Where(project => project.Language == LanguageNames.CSharp).OrderBy(project => project.FilePath, StringComparer.Ordinal)) + { + if (query.Project is not null && !string.Equals(query.Project, project.Name, StringComparison.Ordinal) && !string.Equals(query.Project.Replace('\\', '/'), Relative(root, project.FilePath!), StringComparison.Ordinal)) continue; + var symbols = await SymbolFinder.FindSourceDeclarationsAsync(project, query.Name, ignoreCase: false, cancellationToken: cancellationToken).ConfigureAwait(false); + foreach (var symbol in symbols.Where(symbol => string.Equals(symbol.Name, query.Name, StringComparison.Ordinal))) + { + if (query.Kind is not null && !string.Equals(query.Kind, Kind(symbol), StringComparison.OrdinalIgnoreCase)) continue; + if (query.Container is not null && !string.Equals(query.Container, symbol.ContainingSymbol?.ToDisplayString(), StringComparison.Ordinal)) continue; + if (query.Signature is not null && !string.Equals(query.Signature, symbol.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat), StringComparison.Ordinal)) continue; + var locations = symbol.Locations.Where(location => location.IsInSource && location.SourceTree?.FilePath is not null).ToArray(); + if (!locations.Any(location => + { + var path = pathService.GetCanonicalFullPath(location.SourceTree!.FilePath); + var span = location.GetLineSpan(); + return DotNetWorkspaceTargetResolver.IsWithin(root, path) + && (selectedPath is null || string.Equals(selectedPath, path, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)) + && (query.Line is null || query.Line == span.StartLinePosition.Line + 1) + && (query.Column is null || query.Column == span.StartLinePosition.Character + 1); + })) continue; + if (!result.Any(candidate => candidate.Item2.Id == project.Id && SymbolEqualityComparer.Default.Equals(candidate.Item1, symbol))) result.Add((symbol, project)); + } + } + return result; + } + + internal static CSharpSymbolDescriptor Describe(ISymbol symbol, Project project, string root) + => new(symbol.Name, Kind(symbol), symbol.ContainingSymbol?.ToDisplayString() ?? "", symbol.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat), symbol.GetDocumentationCommentId(), Relative(root, project.FilePath!), + symbol.Locations.Where(location => location.IsInSource && location.SourceTree is not null && DotNetWorkspaceTargetResolver.IsWithin(root, location.SourceTree.FilePath)).Select(location => DescribeLocation(location, root)).Distinct().OrderBy(location => location.Path, StringComparer.Ordinal).ThenBy(location => location.Line).ToArray()); + + internal static CSharpSymbolLocation DescribeLocation(Location location, string root) + { + var span = location.GetLineSpan(); + return new CSharpSymbolLocation(Relative(root, span.Path), span.StartLinePosition.Line + 1, span.StartLinePosition.Character + 1, span.EndLinePosition.Line + 1, span.EndLinePosition.Character + 1); + } + + private static string Kind(ISymbol symbol) => symbol is INamedTypeSymbol type ? type.IsRecord ? "record" : type.TypeKind.ToString().ToLowerInvariant() : symbol.Kind.ToString().ToLowerInvariant(); + private static string Relative(string root, string path) => DotNetWorkspaceTargetResolver.Relative(root, path); + + /// + public void Dispose() => cache.Dispose(); +} diff --git a/src/SharpClaw.Code.Memory/Services/DotNetWorkspaceTargetResolver.cs b/src/SharpClaw.Code.Memory/Services/DotNetWorkspaceTargetResolver.cs new file mode 100644 index 0000000..1aea9e4 --- /dev/null +++ b/src/SharpClaw.Code.Memory/Services/DotNetWorkspaceTargetResolver.cs @@ -0,0 +1,49 @@ +using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Memory.Abstractions; +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.Memory.Services; + +/// Resolves deterministic, canonical .NET targets without evaluating MSBuild. +public sealed class DotNetWorkspaceTargetResolver(IFileSystem fileSystem, IPathService pathService) : IDotNetWorkspaceTargetResolver +{ + /// + public Task ResolveAsync(DotNetWorkspaceRequest request, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + ArgumentException.ThrowIfNullOrWhiteSpace(request.WorkspaceRoot); + var root = pathService.GetCanonicalFullPath(request.WorkspaceRoot); + if (!fileSystem.DirectoryExists(root)) + { + return Task.FromResult(new DotNetWorkspaceTarget(DotNetWorkspaceStatus.NoTarget, root, null, [], "Workspace directory does not exist.")); + } + + if (!string.IsNullOrWhiteSpace(request.Target)) + { + var target = pathService.GetCanonicalFullPath(Path.IsPathRooted(request.Target) ? request.Target : Path.Combine(root, request.Target)); + var valid = IsWithin(root, target) && IsSupported(target) && fileSystem.FileExists(target); + return Task.FromResult(new DotNetWorkspaceTarget(valid ? DotNetWorkspaceStatus.Ready : DotNetWorkspaceStatus.InvalidTarget, root, valid ? target : null, [], valid ? null : "Target must be an existing .sln, .slnx, or .csproj within the workspace.")); + } + + var targets = fileSystem.EnumerateFiles(root, "*").Where(IsSupported) + .Select(pathService.GetCanonicalFullPath).Where(path => IsWithin(root, path)).Order(StringComparer.Ordinal).ToArray(); + var solutions = targets.Where(path => !path.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase)).ToArray(); + var candidates = solutions.Length > 0 ? solutions : targets; + return Task.FromResult(candidates.Length switch + { + 1 => new DotNetWorkspaceTarget(DotNetWorkspaceStatus.Ready, root, candidates[0], []), + 0 => new DotNetWorkspaceTarget(DotNetWorkspaceStatus.NoTarget, root, null, [], "No top-level .NET solution or project was found."), + _ => new DotNetWorkspaceTarget(DotNetWorkspaceStatus.AmbiguousTarget, root, null, candidates.Select(path => Relative(root, path)).ToArray(), "Specify a target; multiple plausible .NET targets were found.") + }); + } + + internal static bool IsWithin(string root, string path) + { + var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + return string.Equals(root, path, comparison) || path.StartsWith(Path.TrimEndingDirectorySeparator(root) + Path.DirectorySeparatorChar, comparison); + } + + internal static string Relative(string root, string path) => Path.GetRelativePath(root, path).Replace('\\', '/'); + + private static bool IsSupported(string path) => Path.GetExtension(path).ToLowerInvariant() is ".sln" or ".slnx" or ".csproj"; +} diff --git a/src/SharpClaw.Code.Memory/SharpClaw.Code.Memory.csproj b/src/SharpClaw.Code.Memory/SharpClaw.Code.Memory.csproj index 6ecbb13..1d2dd89 100644 --- a/src/SharpClaw.Code.Memory/SharpClaw.Code.Memory.csproj +++ b/src/SharpClaw.Code.Memory/SharpClaw.Code.Memory.csproj @@ -3,6 +3,8 @@ + + diff --git a/src/SharpClaw.Code.Permissions/Models/PermissionRequestSourceKind.cs b/src/SharpClaw.Code.Permissions/Models/PermissionRequestSourceKind.cs index df1bddb..9c9790d 100644 --- a/src/SharpClaw.Code.Permissions/Models/PermissionRequestSourceKind.cs +++ b/src/SharpClaw.Code.Permissions/Models/PermissionRequestSourceKind.cs @@ -19,4 +19,7 @@ public enum PermissionRequestSourceKind /// An MCP server initiated the request. /// Mcp, + + /// An external client invoked the inbound SharpClaw MCP server. + McpClient, } diff --git a/src/SharpClaw.Code.Protocol/Commands/TurnExecutionResult.cs b/src/SharpClaw.Code.Protocol/Commands/TurnExecutionResult.cs index 1c143a6..d1351ac 100644 --- a/src/SharpClaw.Code.Protocol/Commands/TurnExecutionResult.cs +++ b/src/SharpClaw.Code.Protocol/Commands/TurnExecutionResult.cs @@ -15,6 +15,7 @@ namespace SharpClaw.Code.Protocol.Commands; /// The runtime events emitted during execution. /// Generated spec artifact metadata, when the turn ran in spec mode. /// Structured deep-planning output, when the turn ran in plan mode. +/// Final opt-in verification report, absent when verification did not run. public sealed record TurnExecutionResult( ConversationSession Session, ConversationTurn Turn, @@ -24,4 +25,5 @@ public sealed record TurnExecutionResult( RuntimeCheckpoint? Checkpoint, RuntimeEvent[] Events, SpecArtifactSet? SpecArtifacts = null, - PlanExecutionResult? PlanResult = null); + PlanExecutionResult? PlanResult = null, + VerificationRunReport? Verification = null); diff --git a/src/SharpClaw.Code.Protocol/Events/RuntimeEvent.cs b/src/SharpClaw.Code.Protocol/Events/RuntimeEvent.cs index 62b6a4b..487c181 100644 --- a/src/SharpClaw.Code.Protocol/Events/RuntimeEvent.cs +++ b/src/SharpClaw.Code.Protocol/Events/RuntimeEvent.cs @@ -12,6 +12,10 @@ namespace SharpClaw.Code.Protocol.Events; /// The related turn identifier, if any. /// The UTC timestamp when the event occurred. [JsonPolymorphic(TypeDiscriminatorPropertyName = "$eventType")] +[JsonDerivedType(typeof(VerificationStartedEvent), "verificationStarted")] +[JsonDerivedType(typeof(VerificationCompletedEvent), "verificationCompleted")] +[JsonDerivedType(typeof(VerificationRepairStartedEvent), "verificationRepairStarted")] +[JsonDerivedType(typeof(VerificationRepairCompletedEvent), "verificationRepairCompleted")] [JsonDerivedType(typeof(SessionCreatedEvent), "sessionCreated")] [JsonDerivedType(typeof(SessionForkedEvent), "sessionForked")] [JsonDerivedType(typeof(SessionStateChangedEvent), "sessionStateChanged")] diff --git a/src/SharpClaw.Code.Protocol/Events/VerificationEvents.cs b/src/SharpClaw.Code.Protocol/Events/VerificationEvents.cs new file mode 100644 index 0000000..2b62423 --- /dev/null +++ b/src/SharpClaw.Code.Protocol/Events/VerificationEvents.cs @@ -0,0 +1,10 @@ +using SharpClaw.Code.Protocol.Models; +namespace SharpClaw.Code.Protocol.Events; +/// Raised before an authorized verification attempt; zero is the initial check. +public sealed record VerificationStartedEvent(string EventId, string SessionId, string? TurnId, DateTimeOffset OccurredAtUtc, int Attempt, VerificationScope Scope, int MaxRepairIterations) : RuntimeEvent(EventId, SessionId, TurnId, OccurredAtUtc); +/// Raised once with a completed verification report. +public sealed record VerificationCompletedEvent(string EventId, string SessionId, string? TurnId, DateTimeOffset OccurredAtUtc, int Attempt, VerificationRunReport Report) : RuntimeEvent(EventId, SessionId, TurnId, OccurredAtUtc); +/// Durably consumes a repair attempt before invoking the agent. +public sealed record VerificationRepairStartedEvent(string EventId, string SessionId, string? TurnId, DateTimeOffset OccurredAtUtc, int Attempt) : RuntimeEvent(EventId, SessionId, TurnId, OccurredAtUtc); +/// Reports the repair agent's completion without implying that re-verification passed. +public sealed record VerificationRepairCompletedEvent(string EventId, string SessionId, string? TurnId, DateTimeOffset OccurredAtUtc, int Attempt, bool Succeeded, string Summary) : RuntimeEvent(EventId, SessionId, TurnId, OccurredAtUtc); diff --git a/src/SharpClaw.Code.Protocol/Models/CSharpRenameModels.cs b/src/SharpClaw.Code.Protocol/Models/CSharpRenameModels.cs new file mode 100644 index 0000000..3ec556c --- /dev/null +++ b/src/SharpClaw.Code.Protocol/Models/CSharpRenameModels.cs @@ -0,0 +1,8 @@ +namespace SharpClaw.Code.Protocol.Models; + +/// A physical text edit with optimistic concurrency preconditions. +public sealed record CSharpRenameChange(string Path, string ContentBefore, string ContentAfter); +/// The fully calculated rename, before any files are written. +public sealed record CSharpRenamePlan(CSharpSymbolResolutionResult Resolution, string NewName, CSharpRenameChange[] Changes, string[] Errors); +/// The committed or rolled-back rename outcome. Residual paths remain recoverable mutations. +public sealed record CSharpRenameResult(bool Succeeded, CSharpSymbolResolutionResult Resolution, string NewName, string[] ChangedPaths, string[] Errors, string[] ResidualPaths); diff --git a/src/SharpClaw.Code.Protocol/Models/CSharpToolModels.cs b/src/SharpClaw.Code.Protocol/Models/CSharpToolModels.cs new file mode 100644 index 0000000..cc50fbf --- /dev/null +++ b/src/SharpClaw.Code.Protocol/Models/CSharpToolModels.cs @@ -0,0 +1,25 @@ +namespace SharpClaw.Code.Protocol.Models; + +/// Portable semantic tool input. Host context supplies the workspace and evaluation authorization. +public sealed record CSharpToolArguments( + string? Target = null, string Configuration = "Debug", string? TargetFramework = null, + string? Name = null, string? Container = null, string? Path = null, string? Kind = null, + string? Project = null, string? Signature = null, int? Line = null, int? Column = null, + string? Severity = null, string? Code = null, int Offset = 0, int Limit = 50, string? NewName = null) +{ + /// Creates the compilation context within a host-owned boundary. + public DotNetWorkspaceRequest Workspace(string root) => new(root, Target, Configuration, TargetFramework); + /// Creates exact symbol selectors. + public CSharpSymbolQuery Query() => new(Name ?? throw new ArgumentException("name is required."), Container, Path, Kind, Project, Signature, Line, Column); +} + +/// A compiler-resolved use site in one project context. +public sealed record CSharpReference(string ProjectPath, CSharpSymbolLocation Location, string Kind); +/// Reports paged references and exact resolution without guessing. +public sealed record CSharpReferenceResult(CSharpSymbolResolutionResult Resolution, CSharpReference[] References, int Total, int Offset, bool Truncated); +/// Reports a type's base, interfaces, derived types and implementations. +public sealed record CSharpTypeHierarchyResult(CSharpSymbolResolutionResult Resolution, CSharpSymbolDescriptor? BaseType, CSharpSymbolDescriptor[] Interfaces, CSharpSymbolDescriptor[] DerivedTypes, CSharpSymbolDescriptor[] Implementations, bool Truncated); +/// A compiler diagnostic with source and compilation attribution. +public sealed record CSharpDiagnostic(string ProjectPath, string? TargetFramework, string Code, string Severity, string Message, CSharpSymbolLocation? Location); +/// Reports paged compiler diagnostics without running project analyzers or generators. +public sealed record CSharpDiagnosticsResult(DotNetWorkspaceStatus Status, CSharpDiagnostic[] Diagnostics, int Total, int Offset, bool Truncated, string[] Messages); diff --git a/src/SharpClaw.Code.Protocol/Models/ConversationTurn.cs b/src/SharpClaw.Code.Protocol/Models/ConversationTurn.cs index 1d8aa31..9660359 100644 --- a/src/SharpClaw.Code.Protocol/Models/ConversationTurn.cs +++ b/src/SharpClaw.Code.Protocol/Models/ConversationTurn.cs @@ -14,6 +14,7 @@ namespace SharpClaw.Code.Protocol.Models; /// The slash command invoked for the turn, if any. /// The usage snapshot captured for the turn, if any. /// Additional machine-readable turn metadata. +/// Final opt-in verification report, absent when verification did not run. public sealed record ConversationTurn( string Id, string SessionId, @@ -25,4 +26,5 @@ public sealed record ConversationTurn( string? AgentId, string? SlashCommandName, UsageSnapshot? Usage, - Dictionary? Metadata); + Dictionary? Metadata, + VerificationRunReport? Verification = null); diff --git a/src/SharpClaw.Code.Protocol/Models/DotNetSemanticModels.cs b/src/SharpClaw.Code.Protocol/Models/DotNetSemanticModels.cs new file mode 100644 index 0000000..e1a0c57 --- /dev/null +++ b/src/SharpClaw.Code.Protocol/Models/DotNetSemanticModels.cs @@ -0,0 +1,122 @@ +namespace SharpClaw.Code.Protocol.Models; + +/// Identifies the outcome of resolving or loading a .NET workspace. +public enum DotNetWorkspaceStatus +{ + /// The workspace is available. + Ready, + /// Several plausible targets require an explicit selection. + AmbiguousTarget, + /// The workspace has no supported target. + NoTarget, + /// The target escapes the workspace or is unsupported. + InvalidTarget, + /// Project evaluation was not authorized. + PermissionDenied, + /// Required SDK or build tools are unavailable. + SdkUnavailable, + /// Explicit package restore is required. + RestoreRequired, + /// Project loading failed. + LoadFailed, + /// Some projects or references could not be loaded. + Partial +} + +/// Selects a semantic workspace and its compilation context. Contains no execution authorization. +/// Workspace boundary. +/// Optional solution or project path within the workspace. +/// MSBuild configuration. +/// Optional framework for multi-targeted projects. +public sealed record DotNetWorkspaceRequest(string WorkspaceRoot, string? Target = null, string Configuration = "Debug", string? TargetFramework = null); + +/// Describes deterministic, non-executing target discovery. +/// Discovery outcome. +/// Canonical workspace root. +/// Selected canonical path, if any. +/// Workspace-relative candidates when ambiguous. +/// Explanation of unsuccessful discovery. +public sealed record DotNetWorkspaceTarget(DotNetWorkspaceStatus Status, string WorkspaceRoot, string? TargetPath, string[] Candidates, string? Message = null); + +/// Describes a evaluated package reference. +/// Package identifier. +/// Resolved version when available. +public sealed record DotNetPackageReference(string Name, string? Version); + +/// Describes a project dependency edge. +/// Referencing project. +/// Referenced project. +public sealed record DotNetProjectReference(string ProjectPath, string ReferencedProjectPath); + +/// Describes a loaded project without exposing compiler types. +/// Project name. +/// Workspace-relative project path. +/// Compiler language. +/// Declared/restored target frameworks. +/// Framework of this compilation. +/// Test-project heuristic. +/// Evaluated output assembly path, relative to the workspace when contained. +/// Evaluated source paths used for affected ownership. +/// Source document count. +/// Evaluated project references. +/// Declared packages and available resolved versions. +public sealed record DotNetProjectSummary(string Name, string Path, string Language, string[] TargetFrameworks, string? EvaluatedTargetFramework, bool IsTestProject, int DocumentCount, string[] ProjectReferences, DotNetPackageReference[] PackageReferences, string[]? SourcePaths = null, string? OutputPath = null); + +/// Describes the solution/project graph and load limitations. +/// Load outcome. +/// Canonical root. +/// Workspace-relative selected target. +/// Loaded projects. +/// Evaluated dependency edges. +/// Load diagnostics and limitations. +public sealed record DotNetSolutionSummary(DotNetWorkspaceStatus Status, string WorkspaceRoot, string? TargetPath, DotNetProjectSummary[] Projects, DotNetProjectReference[] ProjectReferences, string[] Messages); + +/// Locates an exact source symbol; extra selectors disambiguate overloads and projects. +/// Exact symbol name. +/// Optional containing namespace/type. +/// Optional declaration path. +/// Optional class/interface/record/method/property/etc. kind. +/// Optional project name or path. +/// Optional fully qualified display signature. +/// Optional one-based declaration line. +/// Optional one-based declaration column. +public sealed record CSharpSymbolQuery(string Name, string? Container = null, string? Path = null, string? Kind = null, string? Project = null, string? Signature = null, int? Line = null, int? Column = null); + +/// Describes a one-based source location. +/// Workspace-relative source path. +/// One-based line. +/// One-based column. +/// One-based end line. +/// One-based end column. +public sealed record CSharpSymbolLocation(string Path, int Line, int Column, int EndLine, int EndColumn); + +/// Describes an exact compiler symbol in one project context. +/// Symbol name. +/// Portable symbol kind. +/// Containing namespace/type. +/// Fully qualified display signature. +/// Compiler documentation identifier, when available. +/// Workspace-relative defining project. +/// Source declarations. +public sealed record CSharpSymbolDescriptor(string Name, string Kind, string Container, string Signature, string? DocumentationId, string ProjectPath, CSharpSymbolLocation[] Locations); + +/// Identifies exact symbol lookup outcomes. +public enum CSharpSymbolResolutionStatus +{ + /// One exact source symbol remains. + Resolved, + /// Several candidates require more selectors. + Ambiguous, + /// No matching source symbol exists. + NotFound, + /// Workspace discovery/loading did not succeed. + WorkspaceUnavailable +} + +/// Reports an exact lookup without guessing when ambiguous. +/// Lookup outcome. +/// Workspace load outcome. +/// Matching source symbols. +/// Selected symbol when exactly one matches. +/// Load/lookup explanations. +public sealed record CSharpSymbolResolutionResult(CSharpSymbolResolutionStatus Status, DotNetWorkspaceStatus WorkspaceStatus, CSharpSymbolDescriptor[] Candidates, CSharpSymbolDescriptor? Symbol = null, string[]? Messages = null); diff --git a/src/SharpClaw.Code.Protocol/Models/OpenCodeParityModels.cs b/src/SharpClaw.Code.Protocol/Models/OpenCodeParityModels.cs index 6240e13..840c357 100644 --- a/src/SharpClaw.Code.Protocol/Models/OpenCodeParityModels.cs +++ b/src/SharpClaw.Code.Protocol/Models/OpenCodeParityModels.cs @@ -181,6 +181,7 @@ public sealed record ConnectLinkDefinition( /// Optional auth/connect entry points. /// External agent adapter configuration. /// Skill-pack ecosystem configuration. +/// Opt-in post-mutation verification and repair configuration. /// Work-item integration configuration. public sealed record SharpClawConfigDocument( ShareMode? ShareMode, @@ -192,7 +193,8 @@ public sealed record SharpClawConfigDocument( List? ConnectLinks, ExternalAgentsConfig? ExternalAgents = null, SkillPacksConfig? SkillPacks = null, - WorkItemsConfig? WorkItems = null); + WorkItemsConfig? WorkItems = null, + VerificationOptions? Verification = null); /// /// Materialized configuration snapshot after user/workspace precedence is applied. diff --git a/src/SharpClaw.Code.Protocol/Models/SharpClawWorkflowMetadataKeys.cs b/src/SharpClaw.Code.Protocol/Models/SharpClawWorkflowMetadataKeys.cs index 08e7c5e..b2ec947 100644 --- a/src/SharpClaw.Code.Protocol/Models/SharpClawWorkflowMetadataKeys.cs +++ b/src/SharpClaw.Code.Protocol/Models/SharpClawWorkflowMetadataKeys.cs @@ -109,4 +109,12 @@ public static string GetManagedSessionTodoMapKey(string ownerAgentId) ArgumentException.ThrowIfNullOrWhiteSpace(ownerAgentId); return ManagedSessionTodoMapPrefix + ownerAgentId.Trim(); } + /// Overrides automatic verification enablement. + public const string VerificationEnabled = "sharpclaw.verification.enabled"; + /// Overrides automatic verification scope. + public const string VerificationScope = "sharpclaw.verification.scope"; + /// Overrides the bounded repair budget. + public const string VerificationMaxRepairIterations = "sharpclaw.verification.maxRepairIterations"; + /// Explicitly enables restore for automatic verification. + public const string VerificationAllowRestore = "sharpclaw.verification.allowRestore"; } diff --git a/src/SharpClaw.Code.Protocol/Models/VerificationModels.cs b/src/SharpClaw.Code.Protocol/Models/VerificationModels.cs new file mode 100644 index 0000000..73f32d7 --- /dev/null +++ b/src/SharpClaw.Code.Protocol/Models/VerificationModels.cs @@ -0,0 +1,80 @@ +namespace SharpClaw.Code.Protocol.Models; + +/// The build/test coverage requested by a caller. +public enum VerificationScope +{ + /// Build affected projects and test their reverse dependency closure. + Affected, + /// Build the selected solution/project. + Build, + /// Test existing outputs without building. + Tests, + /// Build and test the entire selected target. + All +} +/// A verification operation's terminal outcome. +public enum VerificationStatus +{ + /// The requested checks passed. + Passed, + /// A check or prerequisite failed. + Failed, + /// No applicable check ran. + Skipped, + /// The caller cancelled execution. + Cancelled +} +/// A stable, machine-readable failure or skip explanation. +public enum VerificationFailureReason +{ + /// No failure. + None, + /// No .NET target. + NoTarget, + /// An explicit target is needed. + AmbiguousTarget, + /// Invalid target or path. + InvalidTarget, + /// Execution permission denied. + PermissionDenied, + /// A compatible SDK is unavailable. + SdkUnavailable, + /// Restore assets are missing. + RestoreRequired, + /// Workspace evaluation failed or was incomplete. + LoadFailed, + /// Build returned a failure. + BuildFailed, + /// Tests failed. + TestFailed, + /// Required test outputs are unavailable. + TestOutputUnavailable, + /// Expected test results are absent or malformed. + TestResultsMissing, + /// No test project was selected. + NoTestProjects, + /// A step exceeded its timeout. + Timeout, + /// A process could not run. + ProcessFailed, + /// Caller cancellation. + Cancelled +} +/// Trusted verification input. Execution must be authorized by the host before invoking the worker. +public sealed record VerificationRequest(string WorkspaceRoot, VerificationScope Scope = VerificationScope.Affected, string? Target = null, string Configuration = "Debug", string? TargetFramework = null, bool Restore = false, string[]? ChangedPaths = null, int TimeoutSeconds = 120); +/// Tool input without a caller-controlled workspace boundary. +public sealed record VerifyWorkspaceArguments(VerificationScope Scope = VerificationScope.Affected, string? Target = null, string Configuration = "Debug", string? TargetFramework = null, bool Restore = false, string[]? ChangedPaths = null, int TimeoutSeconds = 120); +/// A parsed build diagnostic with optional one-based source coordinates. +public sealed record VerificationDiagnostic(string? Path, int? Line, int? Column, string Code, string Severity, string Message, string? ProjectPath); +/// A bounded process step and its actual exit status. +public sealed record VerificationStepReport(string Kind, string Target, VerificationStatus Status, VerificationFailureReason Reason, int? ExitCode, DateTimeOffset StartedAtUtc, DateTimeOffset CompletedAtUtc, long DurationMilliseconds, string Output, string Error, VerificationDiagnostic[]? Diagnostics = null); +/// Build steps and deduplicated compiler/MSBuild diagnostics. +public sealed record VerificationBuildReport(VerificationStatus Status, VerificationStepReport[] Steps, VerificationDiagnostic[] Diagnostics); +/// A TRX test case, with bounded failure details. +public sealed record VerificationTestCaseResult(string ProjectPath, string? TargetFramework, string Name, string Outcome, string? Message, string? StackTrace); +/// Aggregated test execution with explicit skipped or incomplete results. +public sealed record VerificationTestRunReport(VerificationStatus Status, VerificationFailureReason Reason, int Total, int Passed, int Failed, int Skipped, VerificationTestCaseResult[] Cases, VerificationStepReport[] Steps, string[] Messages); +/// The final verification report, independent of model/provider credentials. +public sealed record VerificationRunReport(string WorkspaceRoot, string? Target, VerificationScope Scope, VerificationStatus Status, VerificationFailureReason Reason, DateTimeOffset StartedAtUtc, DateTimeOffset CompletedAtUtc, long DurationMilliseconds, string[] AffectedProjects, VerificationBuildReport? Build, VerificationTestRunReport? Tests, VerificationStepReport? Restore, string Summary, int RepairAttempts = 0); +/// A conservative evaluated-project selection. +public sealed record AffectedProjectSelection(string[] Projects, bool ConservativeFallback); diff --git a/src/SharpClaw.Code.Protocol/Models/VerificationPolicyModels.cs b/src/SharpClaw.Code.Protocol/Models/VerificationPolicyModels.cs new file mode 100644 index 0000000..0fffc99 --- /dev/null +++ b/src/SharpClaw.Code.Protocol/Models/VerificationPolicyModels.cs @@ -0,0 +1,10 @@ +namespace SharpClaw.Code.Protocol.Models; +/// Nullable configuration fields support user/workspace precedence; omitted verification remains disabled. +public sealed record VerificationOptions(bool? Enabled = null, VerificationScope? Scope = null, bool? RunAfterMutatingTurn = null, int? MaxRepairIterations = null, bool? AllowRestore = null); +/// Validated verification behavior for one logical turn. +public sealed record VerificationPolicy(bool Enabled = false, VerificationScope Scope = VerificationScope.Affected, bool RunAfterMutatingTurn = true, int MaxRepairIterations = 2, bool AllowRestore = false); +/// A correlated provider request and its observed events from one agent pass. +public sealed record ProviderInvocationRecord(ProviderRequest Request, ProviderEvent[] Events); + +/// Bounded structured failure input for an agent repair pass. +public sealed record VerificationRepairContext(VerificationStatus Status, VerificationFailureReason Reason, VerificationScope Scope, string? Target, string[] AffectedProjects, VerificationDiagnostic[] Diagnostics, VerificationTestCaseResult[] Tests, string Summary); diff --git a/src/SharpClaw.Code.Protocol/Serialization/ProtocolJsonContext.cs b/src/SharpClaw.Code.Protocol/Serialization/ProtocolJsonContext.cs index 31e2795..a89972e 100644 --- a/src/SharpClaw.Code.Protocol/Serialization/ProtocolJsonContext.cs +++ b/src/SharpClaw.Code.Protocol/Serialization/ProtocolJsonContext.cs @@ -15,7 +15,48 @@ namespace SharpClaw.Code.Protocol.Serialization; PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, UseStringEnumConverter = true)] +[JsonSerializable(typeof(CSharpToolArguments))] +[JsonSerializable(typeof(CSharpReference))] +[JsonSerializable(typeof(CSharpReferenceResult))] +[JsonSerializable(typeof(CSharpTypeHierarchyResult))] +[JsonSerializable(typeof(CSharpDiagnostic))] +[JsonSerializable(typeof(CSharpDiagnosticsResult))] +[JsonSerializable(typeof(CSharpRenameChange))] +[JsonSerializable(typeof(CSharpRenamePlan))] +[JsonSerializable(typeof(CSharpRenameResult))] +[JsonSerializable(typeof(VerificationScope))] +[JsonSerializable(typeof(VerificationStatus))] +[JsonSerializable(typeof(VerificationFailureReason))] +[JsonSerializable(typeof(VerificationRequest))] +[JsonSerializable(typeof(VerifyWorkspaceArguments))] +[JsonSerializable(typeof(VerificationDiagnostic))] +[JsonSerializable(typeof(VerificationStepReport))] +[JsonSerializable(typeof(VerificationBuildReport))] +[JsonSerializable(typeof(VerificationTestCaseResult))] +[JsonSerializable(typeof(VerificationTestRunReport))] +[JsonSerializable(typeof(VerificationRunReport))] +[JsonSerializable(typeof(AffectedProjectSelection))] +[JsonSerializable(typeof(VerificationRepairContext))] +[JsonSerializable(typeof(VerificationOptions))] +[JsonSerializable(typeof(VerificationPolicy))] +[JsonSerializable(typeof(ProviderInvocationRecord))] +[JsonSerializable(typeof(VerificationStartedEvent))] +[JsonSerializable(typeof(VerificationCompletedEvent))] +[JsonSerializable(typeof(VerificationRepairStartedEvent))] +[JsonSerializable(typeof(VerificationRepairCompletedEvent))] [JsonSerializable(typeof(AuthStatus))] +[JsonSerializable(typeof(DotNetWorkspaceStatus))] +[JsonSerializable(typeof(DotNetWorkspaceRequest))] +[JsonSerializable(typeof(DotNetWorkspaceTarget))] +[JsonSerializable(typeof(DotNetPackageReference))] +[JsonSerializable(typeof(DotNetProjectReference))] +[JsonSerializable(typeof(DotNetProjectSummary))] +[JsonSerializable(typeof(DotNetSolutionSummary))] +[JsonSerializable(typeof(CSharpSymbolQuery))] +[JsonSerializable(typeof(CSharpSymbolLocation))] +[JsonSerializable(typeof(CSharpSymbolDescriptor))] +[JsonSerializable(typeof(CSharpSymbolResolutionStatus))] +[JsonSerializable(typeof(CSharpSymbolResolutionResult))] [JsonSerializable(typeof(ApprovalAuthMode))] [JsonSerializable(typeof(ApprovalAuthStatus))] [JsonSerializable(typeof(ApprovalDecision))] diff --git a/src/SharpClaw.Code.Runtime/Composition/RuntimeServiceCollectionExtensions.cs b/src/SharpClaw.Code.Runtime/Composition/RuntimeServiceCollectionExtensions.cs index f962475..d809d43 100644 --- a/src/SharpClaw.Code.Runtime/Composition/RuntimeServiceCollectionExtensions.cs +++ b/src/SharpClaw.Code.Runtime/Composition/RuntimeServiceCollectionExtensions.cs @@ -30,6 +30,9 @@ using SharpClaw.Code.Runtime.Server; using SharpClaw.Code.Runtime.Workflow; using SharpClaw.Code.Runtime.Turns; +using SharpClaw.Code.Runtime.Verification; +using SharpClaw.Code.Runtime.Verification.Tools; +using SharpClaw.Code.Tools.Abstractions; using SharpClaw.Code.Runtime.WorkItems; using SharpClaw.Code.Sessions.Abstractions; using SharpClaw.Code.Sessions.Storage; @@ -144,6 +147,12 @@ private static IServiceCollection AddSharpClawRuntimeCore( services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -154,6 +163,9 @@ private static IServiceCollection AddSharpClawRuntimeCore( services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); AddOperationalDiagnostics(services); diff --git a/src/SharpClaw.Code.Runtime/Configuration/SharpClawConfigService.cs b/src/SharpClaw.Code.Runtime/Configuration/SharpClawConfigService.cs index 8f170a6..70118c6 100644 --- a/src/SharpClaw.Code.Runtime/Configuration/SharpClawConfigService.cs +++ b/src/SharpClaw.Code.Runtime/Configuration/SharpClawConfigService.cs @@ -95,7 +95,8 @@ private static SharpClawConfigDocument Merge(SharpClawConfigDocument? user, Shar MergeByKey(user?.ConnectLinks, workspace?.ConnectLinks, static item => item.Target), MergeExternalAgents(user?.ExternalAgents, workspace?.ExternalAgents), workspace?.SkillPacks ?? user?.SkillPacks ?? new SkillPacksConfig(), - workspace?.WorkItems ?? user?.WorkItems ?? new WorkItemsConfig()); + workspace?.WorkItems ?? user?.WorkItems ?? new WorkItemsConfig(), + MergeVerification(user?.Verification, workspace?.Verification)); } private static List? MergeByKey( @@ -129,7 +130,13 @@ private static SharpClawConfigDocument CreateDefaultDocument() null, new ExternalAgentsConfig(), new SkillPacksConfig(), - new WorkItemsConfig()); + new WorkItemsConfig(), + new VerificationOptions(false, VerificationScope.Affected, true, 2, false)); + + private static VerificationOptions MergeVerification(VerificationOptions? user, VerificationOptions? workspace) + => new(workspace?.Enabled ?? user?.Enabled ?? false, workspace?.Scope ?? user?.Scope ?? VerificationScope.Affected, + workspace?.RunAfterMutatingTurn ?? user?.RunAfterMutatingTurn ?? true, workspace?.MaxRepairIterations ?? user?.MaxRepairIterations ?? 2, + workspace?.AllowRestore ?? user?.AllowRestore ?? false); private static ExternalAgentsConfig MergeExternalAgents(ExternalAgentsConfig? user, ExternalAgentsConfig? workspace) { diff --git a/src/SharpClaw.Code.Runtime/Diagnostics/WorkspaceDiagnosticsService.cs b/src/SharpClaw.Code.Runtime/Diagnostics/WorkspaceDiagnosticsService.cs index b5a8cb6..c82a247 100644 --- a/src/SharpClaw.Code.Runtime/Diagnostics/WorkspaceDiagnosticsService.cs +++ b/src/SharpClaw.Code.Runtime/Diagnostics/WorkspaceDiagnosticsService.cs @@ -1,5 +1,5 @@ using System.Collections.Concurrent; -using System.Text.RegularExpressions; +using SharpClaw.Code.Runtime.Verification; using Microsoft.Extensions.Logging; using SharpClaw.Code.Infrastructure.Abstractions; using SharpClaw.Code.Infrastructure.Models; @@ -9,13 +9,14 @@ namespace SharpClaw.Code.Runtime.Diagnostics; /// -/// Produces a cached workspace diagnostics snapshot using configured LSP metadata plus .NET build diagnostics when available. +/// Produces a cached workspace diagnostics snapshot using configured LSP metadata plus previously authorized .NET verification diagnostics. Reading snapshots never starts a process. /// public sealed partial class WorkspaceDiagnosticsService( ISharpClawConfigService configService, IProcessRunner processRunner, ISystemClock systemClock, - ILogger logger) : IWorkspaceDiagnosticsService + ILogger logger, + IVerificationDiagnosticsCache? verificationCache = null) : IWorkspaceDiagnosticsService { private static readonly ConcurrentDictionary Cache = new(StringComparer.Ordinal); private static readonly TimeSpan CacheLifetime = TimeSpan.FromSeconds(15); @@ -25,8 +26,12 @@ public sealed partial class WorkspaceDiagnosticsService( public async Task BuildSnapshotAsync(string workspaceRoot, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(workspaceRoot); + _ = processRunner; // Retained constructor compatibility; execution belongs exclusively to verification. + _ = logger; + var verified = verificationCache?.Get(workspaceRoot); if (Cache.TryGetValue(workspaceRoot, out var cached) - && systemClock.UtcNow - cached.GeneratedAtUtc < CacheLifetime) + && systemClock.UtcNow - cached.GeneratedAtUtc < CacheLifetime + && (verified is null || verified.CompletedAtUtc <= cached.GeneratedAtUtc)) { return cached; } @@ -35,27 +40,9 @@ public async Task BuildSnapshotAsync(string worksp var configuredServers = (IReadOnlyList)(config.Document.LspServers ?? []); var diagnostics = new List(); - var buildTarget = FindBuildTarget(workspaceRoot); - if (!string.IsNullOrWhiteSpace(buildTarget)) - { - try - { - var result = await processRunner.RunAsync( - new ProcessRunRequest( - "dotnet", - ["build", buildTarget, "--nologo", "--no-restore", "-consolelogger:NoSummary"], - workspaceRoot, - null), - cancellationToken).ConfigureAwait(false); - - diagnostics.AddRange(ParseDotnetDiagnostics(result.StandardOutput, "dotnet-build")); - diagnostics.AddRange(ParseDotnetDiagnostics(result.StandardError, "dotnet-build")); - } - catch (Exception ex) when (ex is InvalidOperationException or IOException) - { - logger.LogDebug(ex, "Skipping build-backed diagnostics for workspace {WorkspaceRoot}.", workspaceRoot); - } - } + diagnostics.AddRange((verified?.Build?.Diagnostics ?? []).Select(item => new WorkspaceDiagnosticItem( + item.Severity == "warning" ? WorkspaceDiagnosticSeverity.Warning : WorkspaceDiagnosticSeverity.Error, + item.Code, item.Message, item.Path, item.Line, item.Column, "dotnet-build"))); var snapshot = new WorkspaceDiagnosticsSnapshot(workspaceRoot, systemClock.UtcNow, configuredServers, diagnostics); Cache[workspaceRoot] = snapshot; @@ -63,41 +50,6 @@ public async Task BuildSnapshotAsync(string worksp return snapshot; } - private static string? FindBuildTarget(string workspaceRoot) - { - var solution = Directory.EnumerateFiles(workspaceRoot, "*.sln", SearchOption.TopDirectoryOnly).FirstOrDefault(); - if (!string.IsNullOrWhiteSpace(solution)) - { - return Path.GetFileName(solution); - } - - var project = Directory.EnumerateFiles(workspaceRoot, "*.csproj", SearchOption.TopDirectoryOnly).FirstOrDefault(); - return string.IsNullOrWhiteSpace(project) ? null : Path.GetFileName(project); - } - - private static IEnumerable ParseDotnetDiagnostics(string text, string source) - { - foreach (Match match in DotnetDiagnosticRegex().Matches(text ?? string.Empty)) - { - yield return new WorkspaceDiagnosticItem( - string.Equals(match.Groups["severity"].Value, "warning", StringComparison.OrdinalIgnoreCase) - ? WorkspaceDiagnosticSeverity.Warning - : WorkspaceDiagnosticSeverity.Error, - NullIfEmpty(match.Groups["code"].Value), - match.Groups["message"].Value.Trim(), - NullIfEmpty(match.Groups["path"].Value), - ParseNullableInt(match.Groups["line"].Value), - ParseNullableInt(match.Groups["column"].Value), - source); - } - } - - private static int? ParseNullableInt(string value) - => int.TryParse(value, out var parsed) ? parsed : null; - - private static string? NullIfEmpty(string value) - => string.IsNullOrWhiteSpace(value) ? null : value; - private void EvictCacheEntries() { var now = systemClock.UtcNow; @@ -127,6 +79,4 @@ private void EvictCacheEntries() } } - [GeneratedRegex(@"^(?.*?)(?:\((?\d+),(?\d+)\))?:\s*(?error|warning)\s*(?[A-Z]{2,}\d+)?\s*:?\s*(?.+)$", RegexOptions.Multiline | RegexOptions.IgnoreCase)] - private static partial Regex DotnetDiagnosticRegex(); } diff --git a/src/SharpClaw.Code.Runtime/Mutations/CheckpointMutationCoordinator.cs b/src/SharpClaw.Code.Runtime/Mutations/CheckpointMutationCoordinator.cs index bf647b8..46d1ffb 100644 --- a/src/SharpClaw.Code.Runtime/Mutations/CheckpointMutationCoordinator.cs +++ b/src/SharpClaw.Code.Runtime/Mutations/CheckpointMutationCoordinator.cs @@ -51,7 +51,8 @@ public async Task ApplyRecordedMutationsAsync( history.RemoveRange(state.AppliedPrefixLength, history.Count - state.AppliedPrefixLength); } - history.Add(checkpointId); + // Retry after cancellation between session metadata and mutation-set persistence must not append the same id twice. + if (!history.Contains(checkpointId, StringComparer.Ordinal)) history.Add(checkpointId); var nextState = new UndoRedoStateDocument(history, history.Count, []); UndoRedoStateHelper.Assign(md, nextState); diff --git a/src/SharpClaw.Code.Runtime/Mutations/MutationWorkspaceApplier.cs b/src/SharpClaw.Code.Runtime/Mutations/MutationWorkspaceApplier.cs index 93f24fb..77f2139 100644 --- a/src/SharpClaw.Code.Runtime/Mutations/MutationWorkspaceApplier.cs +++ b/src/SharpClaw.Code.Runtime/Mutations/MutationWorkspaceApplier.cs @@ -17,7 +17,7 @@ public sealed class MutationWorkspaceApplier(IFileSystem fileSystem, IPathServic public async Task ApplyInverseAsync(string workspaceRoot, FileMutationOperation operation, CancellationToken cancellationToken) { var resolver = new WorkspacePathResolver(pathService); - var root = pathService.GetFullPath(workspaceRoot); + var root = pathService.GetCanonicalFullPath(workspaceRoot); var context = CreateBypassContext(root); var fullPath = resolver.ResolvePath(context, operation.RelativePath); EnsureWithinWorkspace(root, fullPath, operation.RelativePath); @@ -71,7 +71,7 @@ public async Task ApplyInverseAsync(string workspaceRoot, FileMutationOperation public async Task ApplyForwardAsync(string workspaceRoot, FileMutationOperation operation, CancellationToken cancellationToken) { var resolver = new WorkspacePathResolver(pathService); - var root = pathService.GetFullPath(workspaceRoot); + var root = pathService.GetCanonicalFullPath(workspaceRoot); var context = CreateBypassContext(root); var fullPath = resolver.ResolvePath(context, operation.RelativePath); EnsureWithinWorkspace(root, fullPath, operation.RelativePath); diff --git a/src/SharpClaw.Code.Runtime/Orchestration/ConversationRuntime.cs b/src/SharpClaw.Code.Runtime/Orchestration/ConversationRuntime.cs index 6c1f146..d64eb2d 100644 --- a/src/SharpClaw.Code.Runtime/Orchestration/ConversationRuntime.cs +++ b/src/SharpClaw.Code.Runtime/Orchestration/ConversationRuntime.cs @@ -57,7 +57,9 @@ public sealed class ConversationRuntime( IShareSessionService shareSessionService, IConversationCompactionService conversationCompactionService, IHookDispatcher hookDispatcher, - ILogger logger) : IConversationRuntime, IRuntimeCommandService + ILogger logger, + TurnMutationJournal? mutationJournal = null, + IMutationSetStore? mutationSets = null) : IConversationRuntime, IRuntimeCommandService { private const string LastTurnSequenceKey = "lastTurnSequence"; private const string CanceledTurnReason = "The turn was canceled."; @@ -245,6 +247,10 @@ await AppendEventAsync( runtimeEvents, cancellationToken).ConfigureAwait(false); + var capturedMutations = mutationJournal?.Begin(session.Id, turnId); + IReadOnlyList? completedMutations = null; + string? mutationCheckpointId = null; + var mutationsPersisted = false; try { var effectivePrimary = PrimaryModeResolver.ResolveEffective(request, session); @@ -257,6 +263,8 @@ await AppendEventAsync( }; var turnRunResult = await turnRunner.RunAsync(session, turn, runnerRequest, cancellationToken).ConfigureAwait(false); + completedMutations = turnRunResult.FileMutations; + var verificationFailed = turnRunResult.Verification?.Status is VerificationStatus.Failed or VerificationStatus.Cancelled; SpecArtifactSet? specArtifacts = null; PlanExecutionResult? planResult = null; if (effectivePrimary == PrimaryMode.Plan) @@ -291,6 +299,7 @@ await AppendEventAsync( Output = turnRunResult.Output, CompletedAtUtc = completedAtUtc, Usage = turnRunResult.Usage, + Verification = turnRunResult.Verification, }; ConversationHistoryCache.StoreCompletedTurn(workspacePath, session.Id, completedTurn); @@ -301,6 +310,7 @@ await AppendRuntimeEventsAsync( runtimeEvents, cancellationToken).ConfigureAwait(false); + runtimeEvents.AddRange(turnRunResult.PersistedRuntimeEvents ?? []); await AppendProviderEventsAsync( workspacePath, session.Id, @@ -322,6 +332,7 @@ await AppendEventAsync( cancellationToken).ConfigureAwait(false); var checkpointId = CreateIdentifier("checkpoint"); + mutationCheckpointId = checkpointId; var checkpoint = new RuntimeCheckpoint( Id: checkpointId, SessionId: session.Id, @@ -347,7 +358,7 @@ await AppendEventAsync( session = session with { - State = activeState, + State = verificationFailed ? stateMachine.Transition(activeState, RuntimeLifecycleTransition.Fail) : activeState, UpdatedAtUtc = completedAtUtc, ActiveTurnId = null, LastCheckpointId = checkpoint.Id, @@ -365,6 +376,13 @@ await AppendEventAsync( mutations, cancellationToken) .ConfigureAwait(false); + mutationsPersisted = true; + } + if (verificationFailed) + { + await AppendEventAsync(workspacePath, session.Id, + new SessionStateChangedEvent(CreateIdentifier("event"), session.Id, turnId, completedAtUtc, activeState, session.State, turnRunResult.Verification!.Summary), + runtimeEvents, cancellationToken).ConfigureAwait(false); } await AppendEventAsync( @@ -376,7 +394,7 @@ await AppendEventAsync( TurnId: turnId, OccurredAtUtc: completedAtUtc, Turn: completedTurn, - Succeeded: true, + Succeeded: !verificationFailed, Summary: turnRunResult.Summary), runtimeEvents, cancellationToken).ConfigureAwait(false); @@ -415,10 +433,12 @@ await AppendEventAsync( Checkpoint: checkpoint, Events: runtimeEvents.ToArray(), SpecArtifacts: finalSpecArtifacts, - PlanResult: planResult); + PlanResult: planResult, + Verification: turnRunResult.Verification); } catch (OperationCanceledException exception) { + if (!mutationsPersisted) session = await PreserveInterruptedMutationsAsync(workspacePath, session, turnId, completedMutations ?? capturedMutations?.ToSnapshot() ?? [], mutationCheckpointId).ConfigureAwait(false); session = await PersistTurnFailureAsync( workspacePath, session, @@ -435,6 +455,7 @@ await AppendEventAsync( } catch (ProviderExecutionException exception) { + if (!mutationsPersisted) session = await PreserveInterruptedMutationsAsync(workspacePath, session, turnId, completedMutations ?? capturedMutations?.ToSnapshot() ?? [], mutationCheckpointId).ConfigureAwait(false); session = await PersistTurnFailureAsync( workspacePath, session, @@ -452,6 +473,7 @@ await AppendEventAsync( } catch (Exception exception) { + if (!mutationsPersisted) session = await PreserveInterruptedMutationsAsync(workspacePath, session, turnId, completedMutations ?? capturedMutations?.ToSnapshot() ?? [], mutationCheckpointId).ConfigureAwait(false); session = await PersistTurnFailureAsync( workspacePath, session, @@ -466,6 +488,10 @@ await AppendEventAsync( turnId); throw; } + finally + { + mutationJournal?.End(session.Id, turnId); + } } finally { @@ -1246,6 +1272,20 @@ private async Task AppendRuntimeEventsAsync( } } + private async Task PreserveInterruptedMutationsAsync(string workspacePath, ConversationSession session, string turnId, IReadOnlyList operations, string? checkpointId) + { + if (operations.Count == 0) return session; + using var cleanup = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var latest = await sessionStore.GetByIdAsync(workspacePath, session.Id, cleanup.Token).ConfigureAwait(false) ?? session; + var id = checkpointId ?? CreateIdentifier("checkpoint"); + if (mutationSets is not null && await mutationSets.GetAsync(workspacePath, session.Id, id, cleanup.Token).ConfigureAwait(false) is not null) return latest; + var checkpoint = new RuntimeCheckpoint(id, session.Id, turnId, systemClock.UtcNow, "Recoverable edits from an interrupted turn.", + pathService.Combine(".sharpclaw", "sessions", session.Id, "checkpoints", id + ".json"), "Inspect or undo the recorded edits before continuing.", new Dictionary { ["interrupted"] = "true" }); + await checkpointStore.SaveAsync(workspacePath, checkpoint, cleanup.Token).ConfigureAwait(false); + latest = latest with { LastCheckpointId = id }; + return await checkpointMutationCoordinator.ApplyRecordedMutationsAsync(workspacePath, latest, turnId, id, operations, cleanup.Token).ConfigureAwait(false); + } + private async Task AppendProviderEventsAsync( string workspacePath, string sessionId, @@ -1254,6 +1294,16 @@ private async Task AppendProviderEventsAsync( List collectedEvents, CancellationToken cancellationToken) { + if (turnRunResult.ProviderInvocations is { Count: > 0 } invocations) + { + foreach (var invocation in invocations) + { + await AppendProviderEventsAsync(workspacePath, sessionId, turnId, + turnRunResult with { ProviderRequest = invocation.Request, ProviderEvents = invocation.Events, ProviderInvocations = null }, + collectedEvents, cancellationToken).ConfigureAwait(false); + } + return; + } if (turnRunResult.ProviderRequest is null) { return; diff --git a/src/SharpClaw.Code.Runtime/Turns/DefaultTurnRunner.cs b/src/SharpClaw.Code.Runtime/Turns/DefaultTurnRunner.cs index 8f5d232..271d025 100644 --- a/src/SharpClaw.Code.Runtime/Turns/DefaultTurnRunner.cs +++ b/src/SharpClaw.Code.Runtime/Turns/DefaultTurnRunner.cs @@ -7,6 +7,7 @@ using SharpClaw.Code.Protocol.Models; using SharpClaw.Code.Runtime.Abstractions; using SharpClaw.Code.Runtime.Workflow; +using SharpClaw.Code.Runtime.Verification; using SharpClaw.Code.Telemetry.Diagnostics; using SharpClaw.Code.Telemetry; using SharpClaw.Code.Tools.Abstractions; @@ -21,7 +22,10 @@ public sealed class DefaultTurnRunner( PrimaryCodingAgent primaryCodingAgentFallback, IToolExecutor toolExecutor, IPromptContextAssembler promptContextAssembler, - IOptions telemetryOptions) : ITurnRunner + IOptions telemetryOptions, + IVerificationPolicyResolver? verificationPolicy = null, + IVerificationLoopCoordinator? verificationLoop = null, + TurnMutationJournal? journal = null) : ITurnRunner { private readonly ISharpClawAgent[] agentList = agents.ToArray(); @@ -45,7 +49,8 @@ public async Task RunAsync( var agent = ResolveAgent(request); var primaryMode = PrimaryModeResolver.ResolveEffective(request, session); - var mutationAccumulator = new TurnMutationAccumulator(); + var mutationAccumulator = journal?.Find(session.Id, turn.Id) ?? new TurnMutationAccumulator(); + var policy = verificationPolicy is null ? new VerificationPolicy() : await verificationPolicy.ResolveAsync(workingDirectory, request.Metadata, cancellationToken).ConfigureAwait(false); var agentContext = new AgentRunContext( SessionId: session.Id, @@ -86,6 +91,9 @@ public async Task RunAsync( throw; } + var loop = verificationLoop is null ? new VerificationLoopResult(agentResult, null, [], []) + : await verificationLoop.RunAsync(agent, agentContext, agentResult, mutationAccumulator, policy, cancellationToken).ConfigureAwait(false); + agentResult = loop.AgentResult; var mutations = mutationAccumulator.ToSnapshot(); return new TurnRunResult( Output: agentResult.Output, @@ -95,7 +103,8 @@ public async Task RunAsync( ProviderEvents: agentResult.ProviderEvents, ToolResults: agentResult.ToolResults, RuntimeEvents: agentResult.Events, - FileMutations: mutations.Count == 0 ? null : mutations); + FileMutations: mutations.Count == 0 ? null : mutations, + Verification: loop.Verification, ProviderInvocations: loop.ProviderInvocations, PersistedRuntimeEvents: loop.PersistedEvents); } private ISharpClawAgent ResolveAgent(RunPromptRequest request) diff --git a/src/SharpClaw.Code.Runtime/Turns/TurnMutationAccumulator.cs b/src/SharpClaw.Code.Runtime/Turns/TurnMutationAccumulator.cs index f1b938d..2453a82 100644 --- a/src/SharpClaw.Code.Runtime/Turns/TurnMutationAccumulator.cs +++ b/src/SharpClaw.Code.Runtime/Turns/TurnMutationAccumulator.cs @@ -9,16 +9,15 @@ namespace SharpClaw.Code.Runtime.Turns; /// public sealed class TurnMutationAccumulator : IToolMutationRecorder { - private readonly ConcurrentBag operations = new(); + private readonly ConcurrentQueue operations = new(); /// public void Record(FileMutationOperation operation) - => operations.Add(operation); + => operations.Enqueue(operation); /// - /// Returns captured operations in an arbitrary order; consumers should sort by if deterministic ordering is required. - /// For current tools, operations are naturally sequential per turn execution thread. + /// Returns captured operations in their recording order, preserving repeated edits to the same file. /// public IReadOnlyList ToSnapshot() - => operations.OrderBy(o => o.OperationId, StringComparer.Ordinal).ToArray(); + => operations.ToArray(); } diff --git a/src/SharpClaw.Code.Runtime/Turns/TurnMutationJournal.cs b/src/SharpClaw.Code.Runtime/Turns/TurnMutationJournal.cs new file mode 100644 index 0000000..48dcb08 --- /dev/null +++ b/src/SharpClaw.Code.Runtime/Turns/TurnMutationJournal.cs @@ -0,0 +1,13 @@ +using System.Collections.Concurrent; +namespace SharpClaw.Code.Runtime.Turns; +/// Shares a turn's recorder between execution and durable failure finalization, without implicit ambient state. +public sealed class TurnMutationJournal +{ + private readonly ConcurrentDictionary<(string Session, string Turn), TurnMutationAccumulator> active = new(); + /// Begins capture owned by the durable runtime. End must run in its finally block. + public TurnMutationAccumulator Begin(string sessionId, string turnId) => active.GetOrAdd((sessionId, turnId), _ => new()); + /// Gets an active recorder; standalone runners can use their own recorder when none exists. + public TurnMutationAccumulator? Find(string sessionId, string turnId) => active.TryGetValue((sessionId, turnId), out var recorder) ? recorder : null; + /// Releases the completed or failed logical turn's capture. + public void End(string sessionId, string turnId) => active.TryRemove((sessionId, turnId), out _); +} diff --git a/src/SharpClaw.Code.Runtime/Turns/TurnRunResult.cs b/src/SharpClaw.Code.Runtime/Turns/TurnRunResult.cs index 530439c..a8f8879 100644 --- a/src/SharpClaw.Code.Runtime/Turns/TurnRunResult.cs +++ b/src/SharpClaw.Code.Runtime/Turns/TurnRunResult.cs @@ -14,6 +14,9 @@ namespace SharpClaw.Code.Runtime.Turns; /// The tool results produced during execution, if any. /// The runtime events emitted during agent execution, if any. /// Reversible file mutations performed via tools during the turn, if any. +/// Provider request/event pairs from every agent pass. +/// Loop events already durably published during execution. +/// Final opt-in verification report, absent when verification did not run. public sealed record TurnRunResult( string Output, UsageSnapshot Usage, @@ -22,4 +25,7 @@ public sealed record TurnRunResult( IReadOnlyList? ProviderEvents = null, IReadOnlyList? ToolResults = null, IReadOnlyList? RuntimeEvents = null, - IReadOnlyList? FileMutations = null); + IReadOnlyList? FileMutations = null, + VerificationRunReport? Verification = null, + IReadOnlyList? ProviderInvocations = null, + IReadOnlyList? PersistedRuntimeEvents = null); diff --git a/src/SharpClaw.Code.Runtime/Verification/AffectedProjectResolver.cs b/src/SharpClaw.Code.Runtime/Verification/AffectedProjectResolver.cs new file mode 100644 index 0000000..a892a91 --- /dev/null +++ b/src/SharpClaw.Code.Runtime/Verification/AffectedProjectResolver.cs @@ -0,0 +1,30 @@ +using SharpClaw.Code.Protocol.Models; +namespace SharpClaw.Code.Runtime.Verification; +/// Uses evaluated compile items, then includes consuming projects and their tests. +public sealed class AffectedProjectResolver : IAffectedProjectResolver +{ + /// + public AffectedProjectSelection Resolve(DotNetSolutionSummary solution, IReadOnlyList? changedPaths) + { + var comparer = OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + AffectedProjectSelection All() => new(solution.Projects.Select(project => project.Path).Order(comparer).ToArray(), true); + if (changedPaths is null || changedPaths.Count == 0 || solution.Status != DotNetWorkspaceStatus.Ready) return All(); + var selected = new HashSet(comparer); + foreach (var changed in changedPaths) + { + var path = changed.Replace('\\', '/'); + if (!path.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)) return All(); + var owners = solution.Projects.Where(project => project.SourcePaths?.Contains(path, comparer) == true).ToArray(); + // Deleted, linked, excluded or unknown items never imply an empty successful verification. + if (owners.Length == 0) return All(); + foreach (var owner in owners) selected.Add(owner.Path); + } + bool added; + do + { + added = false; + foreach (var edge in solution.ProjectReferences) if (selected.Contains(edge.ReferencedProjectPath)) added |= selected.Add(edge.ProjectPath); + } while (added); + return new(selected.Order(comparer).ToArray(), false); + } +} diff --git a/src/SharpClaw.Code.Runtime/Verification/DotNetDiagnosticParser.cs b/src/SharpClaw.Code.Runtime/Verification/DotNetDiagnosticParser.cs new file mode 100644 index 0000000..919e3f1 --- /dev/null +++ b/src/SharpClaw.Code.Runtime/Verification/DotNetDiagnosticParser.cs @@ -0,0 +1,36 @@ +using System.Text.RegularExpressions; +using SharpClaw.Code.Protocol.Models; +namespace SharpClaw.Code.Runtime.Verification; +/// Recognizes compiler/MSBuild diagnostics without deriving success from log content. +public sealed partial class DotNetDiagnosticParser : IDotNetDiagnosticParser +{ + /// + public VerificationDiagnostic[] Parse(string standardOutput, string standardError, string workspaceRoot) + { + var result = new List(); + foreach (var line in (standardOutput + "\n" + standardError).Split('\n')) + { + var match = DiagnosticPattern().Match(line.TrimEnd('\r')); + if (!match.Success) continue; + var origin = match.Groups["origin"].Value.Trim(); + var location = LocationPattern().Match(origin); + string? path = location.Success ? location.Groups["path"].Value : origin; + if (path is "" or "MSBUILD" || path?.StartsWith("CSC", StringComparison.OrdinalIgnoreCase) == true) path = null; + result.Add(new(Relative(path), Number(location.Groups["line"].Value), Number(location.Groups["column"].Value), match.Groups["code"].Value, match.Groups["severity"].Value.ToLowerInvariant(), Bound(match.Groups["message"].Value.Trim(), 4000), Relative(match.Groups["project"].Value))); + } + return result.Distinct().Take(500).ToArray(); + string? Relative(string? path) + { + if (string.IsNullOrWhiteSpace(path)) return null; + var normalized = path.Replace('\\', '/'); + var root = workspaceRoot.Replace('\\', '/').TrimEnd('/') + "/"; + return normalized.StartsWith(root, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal) ? normalized[root.Length..] : normalized; + } + } + internal static string Bound(string value, int limit) => value.Length <= limit ? value : value[..limit] + "\n[truncated]"; + private static int? Number(string value) => int.TryParse(value, out var number) ? number : null; + [GeneratedRegex(@"^\s*(?:(?.*?)\s*:\s*)?(?error|warning)\s+(?[A-Za-z]+\d+)\s*:\s*(?.*?)(?:\s+\[(?[^\]]+)\])?\s*$", RegexOptions.IgnoreCase)] + private static partial Regex DiagnosticPattern(); + [GeneratedRegex(@"^(?.*)\((?\d+),(?\d+)(?:,\d+,\d+)?\)$")] + private static partial Regex LocationPattern(); +} diff --git a/src/SharpClaw.Code.Runtime/Verification/IAffectedProjectResolver.cs b/src/SharpClaw.Code.Runtime/Verification/IAffectedProjectResolver.cs new file mode 100644 index 0000000..fa2e9ea --- /dev/null +++ b/src/SharpClaw.Code.Runtime/Verification/IAffectedProjectResolver.cs @@ -0,0 +1,8 @@ +using SharpClaw.Code.Protocol.Models; +namespace SharpClaw.Code.Runtime.Verification; +/// Selects evaluated source owners and their reverse dependency closure. +public interface IAffectedProjectResolver +{ + /// Falls back to all loaded projects when ownership is unknown or configuration changed. + AffectedProjectSelection Resolve(DotNetSolutionSummary solution, IReadOnlyList? changedPaths); +} diff --git a/src/SharpClaw.Code.Runtime/Verification/IDotNetDiagnosticParser.cs b/src/SharpClaw.Code.Runtime/Verification/IDotNetDiagnosticParser.cs new file mode 100644 index 0000000..511cf82 --- /dev/null +++ b/src/SharpClaw.Code.Runtime/Verification/IDotNetDiagnosticParser.cs @@ -0,0 +1,8 @@ +using SharpClaw.Code.Protocol.Models; +namespace SharpClaw.Code.Runtime.Verification; +/// Parses portable .NET build output once for all diagnostic consumers. +public interface IDotNetDiagnosticParser +{ + /// Parses and deduplicates stdout/stderr diagnostics, including Windows paths and location-free SDK failures. + VerificationDiagnostic[] Parse(string standardOutput, string standardError, string workspaceRoot); +} diff --git a/src/SharpClaw.Code.Runtime/Verification/ITestResultParser.cs b/src/SharpClaw.Code.Runtime/Verification/ITestResultParser.cs new file mode 100644 index 0000000..4ae3df3 --- /dev/null +++ b/src/SharpClaw.Code.Runtime/Verification/ITestResultParser.cs @@ -0,0 +1,8 @@ +using SharpClaw.Code.Protocol.Models; +namespace SharpClaw.Code.Runtime.Verification; +/// Reads test reports without resolving DTDs or external entities. +public interface ITestResultParser +{ + /// Parses one completed TRX report and associates its cases with a project/framework. + VerificationTestRunReport Parse(string xml, string projectPath, string? targetFramework); +} diff --git a/src/SharpClaw.Code.Runtime/Verification/IVerificationDiagnosticsCache.cs b/src/SharpClaw.Code.Runtime/Verification/IVerificationDiagnosticsCache.cs new file mode 100644 index 0000000..8487953 --- /dev/null +++ b/src/SharpClaw.Code.Runtime/Verification/IVerificationDiagnosticsCache.cs @@ -0,0 +1,10 @@ +using SharpClaw.Code.Protocol.Models; +namespace SharpClaw.Code.Runtime.Verification; +/// Supplies only diagnostics from previously authorized verification; reading never executes project code. +public interface IVerificationDiagnosticsCache +{ + /// Stores the latest report with bounded workspace retention. + void Store(VerificationRunReport report); + /// Gets a recent authorized report, if one exists. + VerificationRunReport? Get(string workspaceRoot); +} diff --git a/src/SharpClaw.Code.Runtime/Verification/IVerificationLoopCoordinator.cs b/src/SharpClaw.Code.Runtime/Verification/IVerificationLoopCoordinator.cs new file mode 100644 index 0000000..a17a49e --- /dev/null +++ b/src/SharpClaw.Code.Runtime/Verification/IVerificationLoopCoordinator.cs @@ -0,0 +1,14 @@ +using SharpClaw.Code.Agents.Abstractions; +using SharpClaw.Code.Agents.Models; +using SharpClaw.Code.Protocol.Events; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Runtime.Turns; +namespace SharpClaw.Code.Runtime.Verification; +/// An aggregate agent outcome with all attempts' accounting and already-persisted loop events. +public sealed record VerificationLoopResult(AgentRunResult AgentResult, VerificationRunReport? Verification, ProviderInvocationRecord[] ProviderInvocations, RuntimeEvent[] PersistedEvents); +/// Coordinates permission-aware verification and bounded agent repair without recursive turns. +public interface IVerificationLoopCoordinator +{ + /// Reuses the same agent, caller context and ordered mutation recorder across attempts. + Task RunAsync(ISharpClawAgent agent, AgentRunContext context, AgentRunResult initial, TurnMutationAccumulator mutations, VerificationPolicy policy, CancellationToken cancellationToken); +} diff --git a/src/SharpClaw.Code.Runtime/Verification/IVerificationPolicyResolver.cs b/src/SharpClaw.Code.Runtime/Verification/IVerificationPolicyResolver.cs new file mode 100644 index 0000000..9b750ee --- /dev/null +++ b/src/SharpClaw.Code.Runtime/Verification/IVerificationPolicyResolver.cs @@ -0,0 +1,8 @@ +using SharpClaw.Code.Protocol.Models; +namespace SharpClaw.Code.Runtime.Verification; +/// Resolves validated request metadata over merged workspace/user verification options. +public interface IVerificationPolicyResolver +{ + /// Reads policy without executing projects. + Task ResolveAsync(string workspaceRoot, IReadOnlyDictionary? metadata, CancellationToken cancellationToken); +} diff --git a/src/SharpClaw.Code.Runtime/Verification/IVerificationService.cs b/src/SharpClaw.Code.Runtime/Verification/IVerificationService.cs new file mode 100644 index 0000000..0aa40bd --- /dev/null +++ b/src/SharpClaw.Code.Runtime/Verification/IVerificationService.cs @@ -0,0 +1,8 @@ +using SharpClaw.Code.Protocol.Models; +namespace SharpClaw.Code.Runtime.Verification; +/// Executes verification for a trusted host. Untrusted callers must use the verify_workspace tool permission boundary. +public interface IVerificationService +{ + /// Verifies a contained workspace. This executes project code and requires prior host authorization. + Task VerifyAsync(VerificationRequest request, CancellationToken cancellationToken); +} diff --git a/src/SharpClaw.Code.Runtime/Verification/Tools/VerifyWorkspaceTool.cs b/src/SharpClaw.Code.Runtime/Verification/Tools/VerifyWorkspaceTool.cs new file mode 100644 index 0000000..a387228 --- /dev/null +++ b/src/SharpClaw.Code.Runtime/Verification/Tools/VerifyWorkspaceTool.cs @@ -0,0 +1,32 @@ +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.Tools.Abstractions; +using SharpClaw.Code.Tools.BuiltIn; +using SharpClaw.Code.Tools.Models; +namespace SharpClaw.Code.Runtime.Verification.Tools; +/// Exposes the trusted verification worker through the normal shell-execution permission boundary. +public sealed class VerifyWorkspaceTool(IVerificationService verification) : SharpClawToolBase, IToolPermissionDeniedResultFactory +{ + /// The stable tool name. + public const string ToolName = "verify_workspace"; + /// + public override ToolDefinition Definition { get; } = new(ToolName, "Build/test a .NET workspace. Executes project code and requires shell permission; restore is opt-in.", ApprovalScope.ShellExecution, false, true, nameof(VerifyWorkspaceArguments), "scope, target, configuration, targetFramework, restore, changedPaths and timeoutSeconds.", ["dotnet", "verification"], """{"type":"object","properties":{"scope":{"type":"string","enum":["affected","build","tests","all"]},"target":{"type":"string"},"configuration":{"type":"string"},"targetFramework":{"type":"string"},"restore":{"type":"boolean","default":false},"changedPaths":{"type":"array","items":{"type":"string"}},"timeoutSeconds":{"type":"integer","minimum":1,"maximum":3600}},"additionalProperties":false}"""); + /// + public override async Task ExecuteAsync(ToolExecutionContext context, ToolExecutionRequest request, CancellationToken cancellationToken) + { + var arguments = DeserializeArguments(request, ProtocolJsonContext.Default.VerifyWorkspaceArguments); + var report = await verification.VerifyAsync(new(context.WorkspaceRoot, arguments.Scope, arguments.Target, arguments.Configuration, arguments.TargetFramework, arguments.Restore, arguments.ChangedPaths, arguments.TimeoutSeconds), cancellationToken).ConfigureAwait(false); + return CreateTypedResult(context, request, report, ProtocolJsonContext.Default.VerificationRunReport, report.Status is VerificationStatus.Passed or VerificationStatus.Skipped, report.Summary); + } + /// + public ToolResult CreateDeniedResult(ToolExecutionContext context, ToolExecutionRequest request, PermissionDecision decision) + { + VerifyWorkspaceArguments arguments; + try { arguments = DeserializeArguments(request, ProtocolJsonContext.Default.VerifyWorkspaceArguments); } + catch (System.Text.Json.JsonException) { arguments = new(); } + var now = DateTimeOffset.UtcNow; + var report = new VerificationRunReport(context.WorkspaceRoot, arguments.Target, arguments.Scope, VerificationStatus.Failed, VerificationFailureReason.PermissionDenied, now, now, 0, [], null, null, null, decision.Reason ?? "Verification execution was denied."); + return CreateTypedResult(context, request, report, ProtocolJsonContext.Default.VerificationRunReport, false, report.Summary); + } +} diff --git a/src/SharpClaw.Code.Runtime/Verification/TrxTestResultParser.cs b/src/SharpClaw.Code.Runtime/Verification/TrxTestResultParser.cs new file mode 100644 index 0000000..64c14f7 --- /dev/null +++ b/src/SharpClaw.Code.Runtime/Verification/TrxTestResultParser.cs @@ -0,0 +1,35 @@ +using System.Xml; +using System.Xml.Linq; +using SharpClaw.Code.Protocol.Models; +namespace SharpClaw.Code.Runtime.Verification; +/// Parses namespace-tolerant TRX results with bounded failure details. +public sealed class TrxTestResultParser : ITestResultParser +{ + /// + public VerificationTestRunReport Parse(string xml, string projectPath, string? targetFramework) + { + try + { + using var reader = XmlReader.Create(new StringReader(xml), new XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null, MaxCharactersInDocument = 16 * 1024 * 1024 }); + var document = XDocument.Load(reader); + var counters = document.Descendants().FirstOrDefault(element => element.Name.LocalName == "Counters"); + if (counters is null) throw new XmlException("TRX counters are missing."); + int Count(string name) => int.TryParse(counters.Attribute(name)?.Value, out var number) && number >= 0 ? number : throw new XmlException("Invalid TRX counter: " + name); + var total = Count("total"); + var passed = Count("passed"); + var failed = Count("failed"); + var skipped = total - passed - failed; + var results = document.Descendants().Where(element => element.Name.LocalName == "UnitTestResult").ToArray(); + if (skipped < 0 || results.Length < total) throw new XmlException("TRX results are incomplete."); + var cases = results.OrderBy(element => string.Equals(element.Attribute("outcome")?.Value, "Failed", StringComparison.OrdinalIgnoreCase) ? 0 : 1).Take(500).Select(element => new VerificationTestCaseResult(projectPath, targetFramework, element.Attribute("testName")?.Value ?? "(unnamed)", element.Attribute("outcome")?.Value ?? "Unknown", Text(element, "Message"), Text(element, "StackTrace"))).ToArray(); + var nonPassing = results.Any(element => element.Attribute("outcome")?.Value is not ("Passed" or "NotExecuted" or "Inconclusive")); + var status = failed > 0 || nonPassing || total == 0 ? VerificationStatus.Failed : VerificationStatus.Passed; + return new(status, status == VerificationStatus.Passed ? VerificationFailureReason.None : VerificationFailureReason.TestFailed, total, passed, failed, skipped, cases, [], []); + } + catch (Exception error) when (error is XmlException or InvalidOperationException) + { + return new(VerificationStatus.Failed, VerificationFailureReason.TestResultsMissing, 0, 0, 0, 0, [], [], [error.Message]); + } + } + private static string? Text(XElement element, string name) => element.Descendants().FirstOrDefault(item => item.Name.LocalName == name)?.Value is { } text ? DotNetDiagnosticParser.Bound(text, 4000) : null; +} diff --git a/src/SharpClaw.Code.Runtime/Verification/VerificationDiagnosticsCache.cs b/src/SharpClaw.Code.Runtime/Verification/VerificationDiagnosticsCache.cs new file mode 100644 index 0000000..c6594ba --- /dev/null +++ b/src/SharpClaw.Code.Runtime/Verification/VerificationDiagnosticsCache.cs @@ -0,0 +1,17 @@ +using System.Collections.Concurrent; +using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Protocol.Models; +namespace SharpClaw.Code.Runtime.Verification; +/// Retains up to fifty recent authorized reports, independently of prompt snapshot caching. +public sealed class VerificationDiagnosticsCache(IPathService paths, ISystemClock clock) : IVerificationDiagnosticsCache +{ + private readonly ConcurrentDictionary reports = new(OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); + /// + public void Store(VerificationRunReport report) + { + reports[paths.GetCanonicalFullPath(report.WorkspaceRoot)] = report; + foreach (var key in reports.OrderBy(pair => pair.Value.CompletedAtUtc).Take(Math.Max(0, reports.Count - 50)).Select(pair => pair.Key)) reports.TryRemove(key, out _); + } + /// + public VerificationRunReport? Get(string workspaceRoot) => reports.TryGetValue(paths.GetCanonicalFullPath(workspaceRoot), out var report) && clock.UtcNow - report.CompletedAtUtc < TimeSpan.FromMinutes(5) ? report : null; +} diff --git a/src/SharpClaw.Code.Runtime/Verification/VerificationFailureContextFormatter.cs b/src/SharpClaw.Code.Runtime/Verification/VerificationFailureContextFormatter.cs new file mode 100644 index 0000000..e2f0f9b --- /dev/null +++ b/src/SharpClaw.Code.Runtime/Verification/VerificationFailureContextFormatter.cs @@ -0,0 +1,16 @@ +using System.Text.Json; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; +namespace SharpClaw.Code.Runtime.Verification; +/// Builds bounded repair input from structured failures rather than raw process logs. +public static class VerificationFailureContextFormatter +{ + /// Formats the current request, prior response, failed diagnostics and test cases. + public static string Format(string originalPrompt, string previousOutput, VerificationRunReport report, int attempt, int limit) + { + var failures = new VerificationRepairContext(report.Status, report.Reason, report.Scope, report.Target, report.AffectedProjects, + (report.Build?.Diagnostics ?? []).Where(item => item.Severity == "error").Take(30).ToArray(), + (report.Tests?.Cases ?? []).Where(item => item.Outcome != "Passed").Take(10).ToArray(), report.Summary); + return $"Repair attempt {attempt} of {limit}. Fix the structured verification failures using the existing tools and permissions. Preserve the requested behavior.\n\nOriginal request:\n{DotNetDiagnosticParser.Bound(originalPrompt, 6000)}\n\nPrevious response:\n{DotNetDiagnosticParser.Bound(previousOutput, 3000)}\n\nVerification failures:\n{DotNetDiagnosticParser.Bound(JsonSerializer.Serialize(failures, ProtocolJsonContext.Default.VerificationRepairContext), 8000)}"; + } +} diff --git a/src/SharpClaw.Code.Runtime/Verification/VerificationLoopCoordinator.cs b/src/SharpClaw.Code.Runtime/Verification/VerificationLoopCoordinator.cs new file mode 100644 index 0000000..b4cb9ec --- /dev/null +++ b/src/SharpClaw.Code.Runtime/Verification/VerificationLoopCoordinator.cs @@ -0,0 +1,98 @@ +using System.Text.Json; +using SharpClaw.Code.Agents.Abstractions; +using SharpClaw.Code.Agents.Models; +using SharpClaw.Code.Permissions.Models; +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Events; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.Runtime.Turns; +using SharpClaw.Code.Sessions.Abstractions; +using SharpClaw.Code.Telemetry; +using SharpClaw.Code.Telemetry.Abstractions; +using SharpClaw.Code.Tools.Abstractions; +using SharpClaw.Code.Tools.Models; +namespace SharpClaw.Code.Runtime.Verification; + +/// Runs one initial check plus the configured repair budget, durably consuming attempts before agent execution. +public sealed class VerificationLoopCoordinator(IToolExecutor tools, IEventStore events, IRuntimeEventPublisher publisher) : IVerificationLoopCoordinator +{ + /// + public async Task RunAsync(ISharpClawAgent agent, AgentRunContext context, AgentRunResult initial, TurnMutationAccumulator mutations, VerificationPolicy policy, CancellationToken cancellationToken) + { + var invocations = new List(); + AddInvocation(initial); + var published = new List(); + if (!policy.Enabled || !policy.RunAfterMutatingTurn || context.PrimaryMode != PrimaryMode.Build || !mutations.ToSnapshot().Any(operation => Supported(operation.RelativePath))) return new(initial, null, invocations.ToArray(), []); + var prior = string.IsNullOrWhiteSpace(context.SessionId) ? [] : (await events.ReadAllAsync(context.WorkingDirectory, context.SessionId, cancellationToken).ConfigureAwait(false)).Where(item => item.TurnId == context.TurnId).ToArray(); + var initialStart = prior.OfType().FirstOrDefault(); + var limit = initialStart is null ? policy.MaxRepairIterations : Math.Min(initialStart.MaxRepairIterations, policy.MaxRepairIterations); + var consumed = prior.OfType().Select(item => item.Attempt).DefaultIfEmpty(0).Max(); + var completed = prior.OfType().LastOrDefault(); + var aggregate = initial; + var report = completed?.Report; + var history = new List(context.ConversationHistory ?? []); + history.Add(new("user", context.UserContent ?? [Text(context.Prompt)])); + if (report is null) report = await VerifyAsync(consumed).ConfigureAwait(false); + while (report.Status == VerificationStatus.Failed && report.Reason is VerificationFailureReason.BuildFailed or VerificationFailureReason.TestFailed && consumed < limit) + { + var attempt = ++consumed; + await PublishAsync(new VerificationRepairStartedEvent(Id(), context.SessionId, context.TurnId, DateTimeOffset.UtcNow, attempt), cancellationToken).ConfigureAwait(false); + history.Add(new("assistant", [Text(DotNetDiagnosticParser.Bound(aggregate.Output, 4000))])); + if (aggregate.ToolResults is { Count: > 0 }) history.Add(new("user", [Text("Prior tool results: " + DotNetDiagnosticParser.Bound(string.Join('\n', aggregate.ToolResults.Select(result => result.ToolName + ": " + (result.StructuredOutputJson ?? result.Output ?? result.ErrorMessage))), 6000))])); + var prompt = VerificationFailureContextFormatter.Format(context.Prompt, aggregate.Output, report, attempt, limit); + try + { + var repaired = await agent.RunAsync(context with { Prompt = prompt, ConversationHistory = history.ToArray(), UserContent = [Text(prompt)], ToolMutationRecorder = mutations }, cancellationToken).ConfigureAwait(false); + AddInvocation(repaired); + aggregate = Aggregate(aggregate, repaired); + await PublishAsync(new VerificationRepairCompletedEvent(Id(), context.SessionId, context.TurnId, DateTimeOffset.UtcNow, attempt, true, repaired.Summary), cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) { throw; } + catch (Exception failure) + { + await PublishAsync(new VerificationRepairCompletedEvent(Id(), context.SessionId, context.TurnId, DateTimeOffset.UtcNow, attempt, false, DotNetDiagnosticParser.Bound(failure.Message, 2000)), cancellationToken).ConfigureAwait(false); + report = report with { Status = VerificationStatus.Failed, Reason = VerificationFailureReason.ProcessFailed, Summary = "Repair agent failed: " + DotNetDiagnosticParser.Bound(failure.Message, 2000), RepairAttempts = consumed }; + break; + } + report = await VerifyAsync(attempt).ConfigureAwait(false); + } + report = report with { RepairAttempts = consumed }; + aggregate = aggregate with { Summary = aggregate.Summary + " Verification: " + report.Status + ". " + report.Summary }; + return new(aggregate, report, invocations.ToArray(), published.ToArray()); + + void AddInvocation(AgentRunResult result) { if (result.ProviderRequest is not null) invocations.Add(new(result.ProviderRequest, (result.ProviderEvents ?? []).ToArray())); } + async Task VerifyAsync(int attempt) + { + if (!prior.OfType().Any(item => item.Attempt == attempt)) await PublishAsync(new VerificationStartedEvent(Id(), context.SessionId, context.TurnId, DateTimeOffset.UtcNow, attempt, policy.Scope, limit), cancellationToken).ConfigureAwait(false); + var arguments = new VerifyWorkspaceArguments(policy.Scope, Restore: policy.AllowRestore, ChangedPaths: mutations.ToSnapshot().Select(operation => operation.RelativePath).Distinct(StringComparer.Ordinal).ToArray()); + var toolContext = new ToolExecutionContext(context.SessionId, context.TurnId, context.WorkingDirectory, context.WorkingDirectory, EffectiveMode(context), OutputFormat.Json, null, context.Model, agent.AgentId, context.Metadata, + AllowedTools: Names(SharpClawWorkflowMetadataKeys.AgentAllowedToolsJson), IsInteractive: context.IsInteractive, SourceKind: PermissionRequestSourceKind.Runtime, + TrustedPluginNames: Names(SharpClawWorkflowMetadataKeys.TrustedPluginNamesJson), TrustedMcpServerNames: Names(SharpClawWorkflowMetadataKeys.TrustedMcpServerNamesJson), PrimaryMode: context.PrimaryMode, MutationRecorder: mutations, ApprovalSettings: context.ApprovalSettings); + var envelope = await tools.ExecuteAsync("verify_workspace", JsonSerializer.Serialize(arguments, ProtocolJsonContext.Default.VerifyWorkspaceArguments), toolContext, cancellationToken).ConfigureAwait(false); + aggregate = aggregate with { ToolResults = [.. aggregate.ToolResults ?? [], envelope.Result] }; + var now = DateTimeOffset.UtcNow; + var verified = envelope.Result.StructuredOutputJson is { } json ? JsonSerializer.Deserialize(json, ProtocolJsonContext.Default.VerificationRunReport) : null; + verified ??= new(context.WorkingDirectory, null, policy.Scope, VerificationStatus.Failed, envelope.PermissionDecision.IsAllowed ? VerificationFailureReason.ProcessFailed : VerificationFailureReason.PermissionDenied, now, now, 0, [], null, null, null, envelope.Result.ErrorMessage ?? "Verification failed."); + verified = verified with { RepairAttempts = consumed }; + await PublishAsync(new VerificationCompletedEvent(Id(), context.SessionId, context.TurnId, now, attempt, verified), cancellationToken).ConfigureAwait(false); + return verified; + } + string[]? Names(string key) => context.Metadata?.TryGetValue(key, out var json) == true ? JsonSerializer.Deserialize(json, ProtocolJsonContext.Default.StringArray) : null; + async Task PublishAsync(RuntimeEvent runtimeEvent, CancellationToken token) + { + await publisher.PublishAsync(runtimeEvent, new RuntimeEventPublishOptions(context.WorkingDirectory, context.SessionId, PersistToSessionStore: !string.IsNullOrWhiteSpace(context.SessionId), ThrowIfPersistenceFails: !string.IsNullOrWhiteSpace(context.SessionId)), token).ConfigureAwait(false); + published.Add(runtimeEvent); + } + } + private static AgentRunResult Aggregate(AgentRunResult earlier, AgentRunResult later) + => later with + { + Usage = new(earlier.Usage.InputTokens + later.Usage.InputTokens, earlier.Usage.OutputTokens + later.Usage.OutputTokens, earlier.Usage.CachedInputTokens + later.Usage.CachedInputTokens, earlier.Usage.TotalTokens + later.Usage.TotalTokens, earlier.Usage.EstimatedCostUsd is null || later.Usage.EstimatedCostUsd is null ? null : earlier.Usage.EstimatedCostUsd + later.Usage.EstimatedCostUsd), + ProviderEvents = [.. earlier.ProviderEvents ?? [], .. later.ProviderEvents ?? []], ToolResults = [.. earlier.ToolResults ?? [], .. later.ToolResults ?? []], Events = [.. earlier.Events ?? [], .. later.Events ?? []] + }; + private static PermissionMode EffectiveMode(AgentRunContext context) => context.Metadata?.TryGetValue(SharpClawWorkflowMetadataKeys.PreferredPermissionMode, out var value) == true && Enum.TryParse(value, true, out var mode) ? (PermissionMode)Math.Min((int)mode, (int)context.PermissionMode) : context.PermissionMode; + private static bool Supported(string path) => Path.GetExtension(path).ToLowerInvariant() is ".cs" or ".csproj" or ".sln" or ".slnx" or ".props" or ".targets" or ".razor" || Path.GetFileName(path) is "global.json" or "packages.lock.json"; + private static ContentBlock Text(string text) => new(ContentBlockKind.Text, text, null, null, null, null); + private static string Id() => "event-" + Guid.NewGuid().ToString("N"); +} diff --git a/src/SharpClaw.Code.Runtime/Verification/VerificationPolicyResolver.cs b/src/SharpClaw.Code.Runtime/Verification/VerificationPolicyResolver.cs new file mode 100644 index 0000000..b2e428f --- /dev/null +++ b/src/SharpClaw.Code.Runtime/Verification/VerificationPolicyResolver.cs @@ -0,0 +1,21 @@ +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Runtime.Abstractions; +namespace SharpClaw.Code.Runtime.Verification; +/// Applies explicit metadata overrides and a hard repair ceiling of five. +public sealed class VerificationPolicyResolver(ISharpClawConfigService configuration) : IVerificationPolicyResolver +{ + /// + public async Task ResolveAsync(string workspaceRoot, IReadOnlyDictionary? metadata, CancellationToken cancellationToken) + { + var options = (await configuration.GetConfigAsync(workspaceRoot, cancellationToken).ConfigureAwait(false)).Document.Verification ?? new(); + var enabled = Boolean(SharpClawWorkflowMetadataKeys.VerificationEnabled, options.Enabled ?? false); + var restore = Boolean(SharpClawWorkflowMetadataKeys.VerificationAllowRestore, options.AllowRestore ?? false); + var scope = options.Scope ?? VerificationScope.Affected; + var repairs = options.MaxRepairIterations ?? 2; + if (metadata?.TryGetValue(SharpClawWorkflowMetadataKeys.VerificationScope, out var scopeText) == true && !Enum.TryParse(scopeText, true, out scope)) throw new ArgumentException("Invalid verification scope."); + if (metadata?.TryGetValue(SharpClawWorkflowMetadataKeys.VerificationMaxRepairIterations, out var repairText) == true && !int.TryParse(repairText, out repairs)) throw new ArgumentException("Invalid verification repair budget."); + if (!Enum.IsDefined(scope) || repairs is < 0 or > 5) throw new ArgumentException("Verification repair budget must be 0–5 and scope must be supported."); + return new(enabled, scope, options.RunAfterMutatingTurn ?? true, repairs, restore); + bool Boolean(string key, bool fallback) => metadata?.TryGetValue(key, out var value) == true ? bool.TryParse(value, out var parsed) ? parsed : throw new ArgumentException("Invalid boolean metadata: " + key) : fallback; + } +} diff --git a/src/SharpClaw.Code.Runtime/Verification/VerificationService.cs b/src/SharpClaw.Code.Runtime/Verification/VerificationService.cs new file mode 100644 index 0000000..a9913cb --- /dev/null +++ b/src/SharpClaw.Code.Runtime/Verification/VerificationService.cs @@ -0,0 +1,181 @@ +using System.ComponentModel; +using SharpClaw.Code.Git.Abstractions; +using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Infrastructure.Models; +using SharpClaw.Code.Memory.Abstractions; +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Tools.Models; +using SharpClaw.Code.Tools.Utilities; + +namespace SharpClaw.Code.Runtime.Verification; + +/// Builds/tests an authorized workspace with bounded reports, explicit restore, and serialized process execution. +public sealed class VerificationService(IProcessRunner processes, IFileSystem files, IPathService paths, ISystemClock clock, IDotNetWorkspaceTargetResolver targets, IDotNetWorkspaceSemanticService semantics, IAffectedProjectResolver affected, IDotNetDiagnosticParser diagnostics, ITestResultParser trx, IGitWorkspaceService git, IVerificationDiagnosticsCache reportCache) : IVerificationService +{ + /// + public async Task VerifyAsync(VerificationRequest request, CancellationToken cancellationToken) + { + if (!Enum.IsDefined(request.Scope) || request.TimeoutSeconds is < 1 or > 3600 || string.IsNullOrWhiteSpace(request.Configuration)) throw new ArgumentException("Invalid verification scope, timeout or configuration."); + var started = clock.UtcNow; + var target = await targets.ResolveAsync(new(request.WorkspaceRoot, request.Target, request.Configuration, request.TargetFramework), cancellationToken).ConfigureAwait(false); + var root = target.WorkspaceRoot; + var targetName = target.TargetPath is null ? null : Path.GetRelativePath(root, target.TargetPath).Replace('\\', '/'); + string[] selected = []; + VerificationBuildReport? build = null; + VerificationTestRunReport? tests = null; + VerificationStepReport? restore = null; + VerificationRunReport Finish(VerificationStatus status, VerificationFailureReason reason, string summary) + { + var completed = clock.UtcNow; + var report = new VerificationRunReport(root, targetName, request.Scope, status, reason, started, completed, (long)(completed - started).TotalMilliseconds, selected, build, tests, restore, summary); + reportCache.Store(report); + return report; + } + if (target.Status != DotNetWorkspaceStatus.Ready) return Finish(target.Status == DotNetWorkspaceStatus.NoTarget ? VerificationStatus.Skipped : VerificationStatus.Failed, Map(target.Status), target.Message ?? target.Status.ToString()); + var context = new ToolExecutionContext("", "", root, root, PermissionMode.DangerFullAccess, OutputFormat.Json, null); + var resolver = new WorkspacePathResolver(paths); + await using var workspaceLock = await files.AcquireExclusiveFileLockAsync(resolver.ResolvePath(context, ".sharpclaw/locks/verification.lock"), cancellationToken).ConfigureAwait(false); + if (request.Restore) + { + restore = await RunAsync("restore", targetName!, ["restore", targetName!, "--nologo", "--disable-build-servers"], root, request.TimeoutSeconds, cancellationToken).ConfigureAwait(false); + if (restore.Status != VerificationStatus.Passed) return Finish(VerificationStatus.Failed, restore.Reason, "Explicit restore failed."); + } + DotNetSolutionSummary workspace; + using (var evaluationTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)) + { + evaluationTimeout.CancelAfter(TimeSpan.FromSeconds(request.TimeoutSeconds)); + try { workspace = await semantics.InspectSolutionAsync(new(root, targetName, request.Configuration, request.TargetFramework), new AuthorizedEvaluation(), evaluationTimeout.Token).ConfigureAwait(false); } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { return Finish(VerificationStatus.Failed, VerificationFailureReason.Timeout, "Workspace evaluation timed out."); } + } + if (workspace.Status is not (DotNetWorkspaceStatus.Ready or DotNetWorkspaceStatus.Partial)) return Finish(VerificationStatus.Failed, Map(workspace.Status), string.Join("; ", workspace.Messages)); + if (workspace.Status == DotNetWorkspaceStatus.Partial && request.Scope != VerificationScope.Build) return Finish(VerificationStatus.Failed, VerificationFailureReason.LoadFailed, "Partial workspace loading cannot establish complete test coverage: " + string.Join("; ", workspace.Messages)); + var changed = request.ChangedPaths; + if (request.Scope == VerificationScope.Affected && changed is null) + { + try + { + var snapshot = await git.GetSnapshotAsync(root, cancellationToken).ConfigureAwait(false); + if (snapshot.IsRepository && snapshot.RepositoryRoot is not null) + { + changed = snapshot.StatusEntries.Select(entry => Path.GetRelativePath(root, Path.Combine(snapshot.RepositoryRoot, entry.Path)).Replace('\\', '/')).ToArray(); + } + } + catch (Exception error) when (error is IOException or InvalidOperationException or Win32Exception) { changed = null; } + } + var selection = request.Scope == VerificationScope.Affected ? affected.Resolve(workspace, changed) : new AffectedProjectSelection(workspace.Projects.Select(project => project.Path).ToArray(), true); + selected = selection.Projects; + if (request.Scope != VerificationScope.Tests) + { + var steps = new List(); + var allDiagnostics = new List(); + var buildTargets = request.Scope == VerificationScope.Affected && !selection.ConservativeFallback ? selected : [targetName!]; + foreach (var buildTarget in buildTargets) + { + var arguments = new List { "build", buildTarget, "--nologo", "--no-restore", "--configuration", request.Configuration, "--disable-build-servers" }; + if (request.TargetFramework is not null) arguments.AddRange(["--framework", request.TargetFramework]); + var step = await RunAsync("build", buildTarget, arguments, root, request.TimeoutSeconds, cancellationToken).ConfigureAwait(false); + steps.Add(step); + allDiagnostics.AddRange(step.Diagnostics ?? []); + if (step.Status != VerificationStatus.Passed) + { + build = new(VerificationStatus.Failed, steps.ToArray(), allDiagnostics.Distinct().ToArray()); + return Finish(VerificationStatus.Failed, step.Reason, "Build failed; tests were not run."); + } + } + build = new(VerificationStatus.Passed, steps.ToArray(), allDiagnostics.Distinct().ToArray()); + } + if (request.Scope == VerificationScope.Build) return Finish(VerificationStatus.Passed, VerificationFailureReason.None, "Build passed."); + var testProjects = workspace.Projects.Where(project => project.IsTestProject && selected.Contains(project.Path, OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal)).ToArray(); + if (testProjects.Length == 0) + { + tests = new(VerificationStatus.Skipped, VerificationFailureReason.NoTestProjects, 0, 0, 0, 0, [], [], ["No test projects were selected."]); + return Finish(build is null ? VerificationStatus.Skipped : VerificationStatus.Passed, VerificationFailureReason.NoTestProjects, build is null ? "No test projects; verification skipped." : "Build passed; no test projects were selected."); + } + var resultRoot = resolver.ResolvePath(context, ".sharpclaw/verification/" + Guid.NewGuid().ToString("N")); + var reports = new List(); + var testSteps = new List(); + try + { + foreach (var project in testProjects) + { + if (request.Scope == VerificationScope.Tests && (project.OutputPath is null || !files.FileExists(resolver.ResolvePath(context, project.OutputPath)))) + { + tests = Merge(reports, testSteps, VerificationStatus.Failed, VerificationFailureReason.TestOutputUnavailable, ["Build the selected test project/configuration before using tests scope: " + project.Path]); + return Finish(VerificationStatus.Failed, VerificationFailureReason.TestOutputUnavailable, "Test output is unavailable; tests scope does not build."); + } + var resultDirectory = Path.Combine(resultRoot, Guid.NewGuid().ToString("N")); + files.CreateDirectory(resultDirectory); + var arguments = new List { "test", project.Path, "--no-build", "--no-restore", "--configuration", request.Configuration, "--logger", "trx", "--results-directory", resultDirectory, "--nologo", "--disable-build-servers" }; + if (request.TargetFramework is not null) arguments.AddRange(["--framework", request.TargetFramework]); + var step = await RunAsync("test", project.Path, arguments, root, request.TimeoutSeconds, cancellationToken).ConfigureAwait(false); + testSteps.Add(step); + var reportPaths = files.EnumerateFiles(resultDirectory, "*.trx").ToArray(); + foreach (var reportPath in reportPaths) + { + var report = await files.ReadAllTextIfExistsAsync(reportPath, cancellationToken).ConfigureAwait(false); + reports.Add(trx.Parse(report ?? "", project.Path, request.TargetFramework ?? (project.TargetFrameworks.Length == 1 ? project.EvaluatedTargetFramework : null))); + } + if (reportPaths.Length == 0 || step.Status != VerificationStatus.Passed || reports.Any(report => report.Status != VerificationStatus.Passed)) + { + var reason = step.Status != VerificationStatus.Passed ? step.Reason : VerificationFailureReason.TestResultsMissing; + if (reports.Any(report => report.Failed > 0)) reason = VerificationFailureReason.TestFailed; + tests = Merge(reports, testSteps, VerificationStatus.Failed, reason, reportPaths.Length == 0 ? ["Expected TRX test results are missing: " + project.Path] : []); + return Finish(VerificationStatus.Failed, reason, "Test verification failed or was incomplete."); + } + } + tests = Merge(reports, testSteps, VerificationStatus.Passed, VerificationFailureReason.None, []); + return Finish(VerificationStatus.Passed, VerificationFailureReason.None, "Build/test verification passed."); + } + finally + { + try { files.DeleteDirectoryRecursive(resultRoot); } + catch (Exception error) when (error is IOException or UnauthorizedAccessException) { /* Only this unique run directory is eligible for best-effort cleanup. */ } + } + } + + private async Task RunAsync(string kind, string target, IReadOnlyList arguments, string root, int timeoutSeconds, CancellationToken caller) + { + var started = clock.UtcNow; + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(caller); + timeout.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds)); + try + { + var result = await processes.RunAsync(new ProcessRunRequest("dotnet", arguments.ToArray(), root, new Dictionary { ["DOTNET_NOLOGO"] = "1", ["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1" }), timeout.Token).ConfigureAwait(false); + var reason = result.ExitCode == 0 ? VerificationFailureReason.None : Classify(result.StandardOutput + result.StandardError, kind); + return new(kind, target, result.ExitCode == 0 ? VerificationStatus.Passed : VerificationStatus.Failed, reason, result.ExitCode, started, clock.UtcNow, (long)(clock.UtcNow - started).TotalMilliseconds, DotNetDiagnosticParser.Bound(result.StandardOutput, 16000), DotNetDiagnosticParser.Bound(result.StandardError, 16000), diagnostics.Parse(result.StandardOutput, result.StandardError, root)); + } + catch (OperationCanceledException) when (!caller.IsCancellationRequested) + { + return Failure(VerificationFailureReason.Timeout, "Verification step timed out."); + } + catch (Exception error) when (error is IOException or Win32Exception or InvalidOperationException) + { + return Failure(error is Win32Exception ? VerificationFailureReason.SdkUnavailable : VerificationFailureReason.ProcessFailed, error.Message); + } + VerificationStepReport Failure(VerificationFailureReason reason, string error) => new(kind, target, VerificationStatus.Failed, reason, null, started, clock.UtcNow, (long)(clock.UtcNow - started).TotalMilliseconds, "", error); + } + private static VerificationFailureReason Classify(string text, string kind) + { + if (text.Contains("NETSDK1004", StringComparison.Ordinal) || text.Contains("project.assets.json", StringComparison.OrdinalIgnoreCase)) return VerificationFailureReason.RestoreRequired; + if (text.Contains("SDK", StringComparison.OrdinalIgnoreCase) && text.Contains("not found", StringComparison.OrdinalIgnoreCase)) return VerificationFailureReason.SdkUnavailable; + return kind == "test" ? VerificationFailureReason.TestFailed : kind == "build" ? VerificationFailureReason.BuildFailed : VerificationFailureReason.ProcessFailed; + } + private static VerificationFailureReason Map(DotNetWorkspaceStatus status) => status switch + { + DotNetWorkspaceStatus.NoTarget => VerificationFailureReason.NoTarget, + DotNetWorkspaceStatus.AmbiguousTarget => VerificationFailureReason.AmbiguousTarget, + DotNetWorkspaceStatus.InvalidTarget => VerificationFailureReason.InvalidTarget, + DotNetWorkspaceStatus.PermissionDenied => VerificationFailureReason.PermissionDenied, + DotNetWorkspaceStatus.SdkUnavailable => VerificationFailureReason.SdkUnavailable, + DotNetWorkspaceStatus.RestoreRequired => VerificationFailureReason.RestoreRequired, + _ => VerificationFailureReason.LoadFailed + }; + private static VerificationTestRunReport Merge(List reports, List steps, VerificationStatus status, VerificationFailureReason reason, string[] messages) + => new(status, reason, reports.Sum(report => report.Total), reports.Sum(report => report.Passed), reports.Sum(report => report.Failed), reports.Sum(report => report.Skipped), reports.SelectMany(report => report.Cases).OrderBy(item => item.Outcome == "Failed" ? 0 : 1).Take(500).ToArray(), steps.ToArray(), [.. reports.SelectMany(report => report.Messages), .. messages]); + private sealed class AuthorizedEvaluation : IDotNetWorkspaceEvaluationAuthorization + { + public Task AuthorizeAsync(DotNetWorkspaceRequest request, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); return Task.FromResult(true); } + } +} diff --git a/src/SharpClaw.Code.Tools/Abstractions/IToolPermissionDeniedResultFactory.cs b/src/SharpClaw.Code.Tools/Abstractions/IToolPermissionDeniedResultFactory.cs new file mode 100644 index 0000000..79bf807 --- /dev/null +++ b/src/SharpClaw.Code.Tools/Abstractions/IToolPermissionDeniedResultFactory.cs @@ -0,0 +1,9 @@ +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Tools.Models; +namespace SharpClaw.Code.Tools.Abstractions; +/// Allows a tool to preserve its typed operation report when execution is denied before invocation. +public interface IToolPermissionDeniedResultFactory +{ + /// Creates a failed result without executing the tool or any project code. + ToolResult CreateDeniedResult(ToolExecutionContext context, ToolExecutionRequest request, PermissionDecision decision); +} diff --git a/src/SharpClaw.Code.Tools/BuiltIn/CSharpDiagnosticsTool.cs b/src/SharpClaw.Code.Tools/BuiltIn/CSharpDiagnosticsTool.cs new file mode 100644 index 0000000..6e9e6d1 --- /dev/null +++ b/src/SharpClaw.Code.Tools/BuiltIn/CSharpDiagnosticsTool.cs @@ -0,0 +1,28 @@ +using SharpClaw.Code.Memory.Abstractions; +using SharpClaw.Code.Permissions.Abstractions; +using SharpClaw.Code.Protocol.Abstractions; +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.Telemetry.Abstractions; +using SharpClaw.Code.Tools.Execution; +using SharpClaw.Code.Tools.Models; + +namespace SharpClaw.Code.Tools.BuiltIn; + +/// Read compiler diagnostics without executing project analyzers or generators. +public sealed class CSharpDiagnosticsTool(IDotNetWorkspaceSemanticService semantics, IPermissionPolicyEngine policy, IRuntimeEventPublisher? publisher = null, IRuntimeHostContextAccessor? host = null) : SharpClawToolBase +{ + /// The stable tool name. + public const string ToolName = "csharp_diagnostics"; + /// + public override ToolDefinition Definition { get; } = new(ToolName, "Read compiler diagnostics without executing project analyzers or generators.", ApprovalScope.ToolExecution, false, false, nameof(CSharpToolArguments), "Typed semantic selectors and workspace context.", ["dotnet", "semantic"], """{"type":"object","properties":{"target":{"type":"string"},"configuration":{"type":"string","default":"Debug"},"targetFramework":{"type":"string"},"project":{"type":"string"},"path":{"type":"string"},"severity":{"type":"string","enum":["error","warning","info","hidden"]},"code":{"type":"string"},"offset":{"type":"integer","minimum":0},"limit":{"type":"integer","minimum":1,"maximum":200}},"required":[],"additionalProperties":false}"""); + /// + public override async Task ExecuteAsync(ToolExecutionContext context, ToolExecutionRequest request, CancellationToken cancellationToken) + { + var arguments = DeserializeArguments(request, ProtocolJsonContext.Default.CSharpToolArguments); + var authorization = new DotNetEvaluationAuthorization(policy, context, request, publisher, host); + var result = await semantics.GetDiagnosticsAsync(arguments.Workspace(context.WorkspaceRoot), arguments, authorization, cancellationToken).ConfigureAwait(false); + return CreateTypedResult(context, request, result, ProtocolJsonContext.Default.CSharpDiagnosticsResult, result.Status is DotNetWorkspaceStatus.Ready or DotNetWorkspaceStatus.Partial, System.Text.Json.JsonSerializer.Serialize(result, ProtocolJsonContext.Default.CSharpDiagnosticsResult)); + } +} diff --git a/src/SharpClaw.Code.Tools/BuiltIn/CSharpFindReferencesTool.cs b/src/SharpClaw.Code.Tools/BuiltIn/CSharpFindReferencesTool.cs new file mode 100644 index 0000000..ae2de9a --- /dev/null +++ b/src/SharpClaw.Code.Tools/BuiltIn/CSharpFindReferencesTool.cs @@ -0,0 +1,28 @@ +using SharpClaw.Code.Memory.Abstractions; +using SharpClaw.Code.Permissions.Abstractions; +using SharpClaw.Code.Protocol.Abstractions; +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.Telemetry.Abstractions; +using SharpClaw.Code.Tools.Execution; +using SharpClaw.Code.Tools.Models; + +namespace SharpClaw.Code.Tools.BuiltIn; + +/// Find compiler-resolved C# references across the loaded solution. +public sealed class CSharpFindReferencesTool(IDotNetWorkspaceSemanticService semantics, IPermissionPolicyEngine policy, IRuntimeEventPublisher? publisher = null, IRuntimeHostContextAccessor? host = null) : SharpClawToolBase +{ + /// The stable tool name. + public const string ToolName = "csharp_find_references"; + /// + public override ToolDefinition Definition { get; } = new(ToolName, "Find compiler-resolved C# references across the loaded solution.", ApprovalScope.ToolExecution, false, false, nameof(CSharpToolArguments), "Typed semantic selectors and workspace context.", ["dotnet", "semantic"], """{"type":"object","properties":{"target":{"type":"string"},"configuration":{"type":"string","default":"Debug"},"targetFramework":{"type":"string"},"name":{"type":"string"},"container":{"type":"string"},"path":{"type":"string"},"kind":{"type":"string"},"project":{"type":"string"},"signature":{"type":"string"},"line":{"type":"integer","minimum":1},"column":{"type":"integer","minimum":1},"offset":{"type":"integer","minimum":0},"limit":{"type":"integer","minimum":1,"maximum":200}},"required":["name"],"additionalProperties":false}"""); + /// + public override async Task ExecuteAsync(ToolExecutionContext context, ToolExecutionRequest request, CancellationToken cancellationToken) + { + var arguments = DeserializeArguments(request, ProtocolJsonContext.Default.CSharpToolArguments); + var authorization = new DotNetEvaluationAuthorization(policy, context, request, publisher, host); + var result = await semantics.FindReferencesAsync(arguments.Workspace(context.WorkspaceRoot), arguments, authorization, cancellationToken).ConfigureAwait(false); + return CreateTypedResult(context, request, result, ProtocolJsonContext.Default.CSharpReferenceResult, result.Resolution.Status == CSharpSymbolResolutionStatus.Resolved, System.Text.Json.JsonSerializer.Serialize(result, ProtocolJsonContext.Default.CSharpReferenceResult)); + } +} diff --git a/src/SharpClaw.Code.Tools/BuiltIn/CSharpRenameSymbolTool.cs b/src/SharpClaw.Code.Tools/BuiltIn/CSharpRenameSymbolTool.cs new file mode 100644 index 0000000..46b2202 --- /dev/null +++ b/src/SharpClaw.Code.Tools/BuiltIn/CSharpRenameSymbolTool.cs @@ -0,0 +1,85 @@ +using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Memory.Abstractions; +using SharpClaw.Code.Permissions.Abstractions; +using SharpClaw.Code.Protocol.Abstractions; +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.Telemetry.Abstractions; +using SharpClaw.Code.Tools.Execution; +using SharpClaw.Code.Tools.Models; +using SharpClaw.Code.Tools.Utilities; + +namespace SharpClaw.Code.Tools.BuiltIn; + +/// Applies a compiler-calculated multi-file rename with content preconditions and recoverable rollback. +public sealed class CSharpRenameSymbolTool(IDotNetWorkspaceSemanticService semantics, IFileSystem fileSystem, IPathService paths, IPermissionPolicyEngine policy, IRuntimeEventPublisher? publisher = null, IRuntimeHostContextAccessor? host = null) : SharpClawToolBase +{ + /// The stable tool name. + public const string ToolName = "csharp_rename_symbol"; + /// + public override ToolDefinition Definition { get; } = new(ToolName, "Rename one exact source symbol, recording all changed files for undo. Cold MSBuild loading requires execution permission.", ApprovalScope.FileSystemWrite, true, true, nameof(CSharpToolArguments), "Exact symbol selectors plus newName and optional target/configuration/framework.", ["dotnet", "semantic", "mutation"], """{"type":"object","properties":{"name":{"type":"string"},"newName":{"type":"string"},"container":{"type":"string"},"kind":{"type":"string"},"project":{"type":"string"},"path":{"type":"string"},"signature":{"type":"string"},"line":{"type":"integer","minimum":1},"column":{"type":"integer","minimum":1},"target":{"type":"string"},"configuration":{"type":"string"},"targetFramework":{"type":"string"}},"required":["name","newName"],"additionalProperties":false}"""); + + /// + public override async Task ExecuteAsync(ToolExecutionContext context, ToolExecutionRequest request, CancellationToken cancellationToken) + { + var arguments = DeserializeArguments(request, ProtocolJsonContext.Default.CSharpToolArguments); + var resolver = new WorkspacePathResolver(paths); + await using var writeLock = await fileSystem.AcquireExclusiveFileLockAsync(resolver.ResolvePath(context, ".sharpclaw/locks/semantic-write.lock"), cancellationToken).ConfigureAwait(false); + var plan = await semantics.PlanRenameAsync(arguments.Workspace(context.WorkspaceRoot), arguments, new DotNetEvaluationAuthorization(policy, context, request, publisher, host), cancellationToken).ConfigureAwait(false); + if (plan.Errors.Length != 0) return Result(new(false, plan.Resolution, plan.NewName, [], plan.Errors, [])); + // Validate every precondition before writing the first byte. + foreach (var change in plan.Changes) await ValidateAsync(change, cancellationToken).ConfigureAwait(false); + var attempted = new List(); + try + { + foreach (var change in plan.Changes) + { + await ValidateAsync(change, cancellationToken).ConfigureAwait(false); + attempted.Add(change); + await fileSystem.WriteAllTextAsync(resolver.ResolvePath(context, change.Path), change.ContentAfter, cancellationToken).ConfigureAwait(false); + } + foreach (var change in attempted) Record(change, change.ContentAfter); + return Result(new(true, plan.Resolution, plan.NewName, attempted.Select(change => change.Path).ToArray(), [], [])); + } + catch (Exception failure) when (failure is IOException or UnauthorizedAccessException or InvalidOperationException or OperationCanceledException) + { + using var cleanup = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var errors = new List { failure.Message }; + var residual = new List(); + foreach (var change in attempted.AsEnumerable().Reverse()) + { + try + { + var path = resolver.ResolvePath(context, change.Path); + var actual = await fileSystem.ReadAllTextIfExistsAsync(path, cleanup.Token).ConfigureAwait(false); + if (actual == change.ContentBefore) continue; + if (actual != change.ContentAfter) throw new IOException("Rollback precondition changed; file requires recovery: " + change.Path); + await fileSystem.WriteAllTextAsync(path, change.ContentBefore, cleanup.Token).ConfigureAwait(false); + } + catch (Exception rollback) when (rollback is IOException or UnauthorizedAccessException or InvalidOperationException or OperationCanceledException) + { + errors.Add(rollback.Message); + residual.Add(change.Path); + // Inspect independently even if the cleanup budget expired; record known after-state if inaccessible. + using var inspect = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + string? actual = change.ContentAfter; + try { actual = await fileSystem.ReadAllTextIfExistsAsync(resolver.ResolvePath(context, change.Path), inspect.Token).ConfigureAwait(false); } + catch (Exception read) when (read is IOException or UnauthorizedAccessException or OperationCanceledException) { errors.Add("Residual state inspection failed: " + read.Message); } + Record(change, actual); + } + } + if (failure is OperationCanceledException) throw; + return Result(new(false, plan.Resolution, plan.NewName, [], errors.ToArray(), residual.ToArray())); + } + + async Task ValidateAsync(CSharpRenameChange change, CancellationToken token) + { + var current = await fileSystem.ReadAllTextIfExistsAsync(resolver.ResolvePath(context, change.Path), token).ConfigureAwait(false); + if (current != change.ContentBefore) throw new InvalidOperationException("Concurrent file change detected: " + change.Path); + } + void Record(CSharpRenameChange change, string? after) => context.MutationRecorder?.Record(new("op-" + Guid.NewGuid().ToString("N"), after is null ? FileMutationKind.Delete : FileMutationKind.Replace, ToolName, change.Path, change.ContentBefore, after ?? change.ContentBefore)); + ToolResult Result(CSharpRenameResult result) => CreateTypedResult(context, request, result, ProtocolJsonContext.Default.CSharpRenameResult, result.Succeeded, result.Succeeded ? "Renamed " + attemptedPaths(result) : string.Join("; ", result.Errors)); + static string attemptedPaths(CSharpRenameResult result) => string.Join(", ", result.ChangedPaths); + } +} diff --git a/src/SharpClaw.Code.Tools/BuiltIn/CSharpSymbolResolveTool.cs b/src/SharpClaw.Code.Tools/BuiltIn/CSharpSymbolResolveTool.cs new file mode 100644 index 0000000..5d2ef7a --- /dev/null +++ b/src/SharpClaw.Code.Tools/BuiltIn/CSharpSymbolResolveTool.cs @@ -0,0 +1,28 @@ +using SharpClaw.Code.Memory.Abstractions; +using SharpClaw.Code.Permissions.Abstractions; +using SharpClaw.Code.Protocol.Abstractions; +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.Telemetry.Abstractions; +using SharpClaw.Code.Tools.Execution; +using SharpClaw.Code.Tools.Models; + +namespace SharpClaw.Code.Tools.BuiltIn; + +/// Resolve one exact C# source symbol; ambiguity returns candidates. +public sealed class CSharpSymbolResolveTool(IDotNetWorkspaceSemanticService semantics, IPermissionPolicyEngine policy, IRuntimeEventPublisher? publisher = null, IRuntimeHostContextAccessor? host = null) : SharpClawToolBase +{ + /// The stable tool name. + public const string ToolName = "csharp_symbol_resolve"; + /// + public override ToolDefinition Definition { get; } = new(ToolName, "Resolve one exact C# source symbol; ambiguity returns candidates.", ApprovalScope.ToolExecution, false, false, nameof(CSharpToolArguments), "Typed semantic selectors and workspace context.", ["dotnet", "semantic"], """{"type":"object","properties":{"target":{"type":"string"},"configuration":{"type":"string","default":"Debug"},"targetFramework":{"type":"string"},"name":{"type":"string"},"container":{"type":"string"},"path":{"type":"string"},"kind":{"type":"string"},"project":{"type":"string"},"signature":{"type":"string"},"line":{"type":"integer","minimum":1},"column":{"type":"integer","minimum":1}},"required":["name"],"additionalProperties":false}"""); + /// + public override async Task ExecuteAsync(ToolExecutionContext context, ToolExecutionRequest request, CancellationToken cancellationToken) + { + var arguments = DeserializeArguments(request, ProtocolJsonContext.Default.CSharpToolArguments); + var authorization = new DotNetEvaluationAuthorization(policy, context, request, publisher, host); + var result = await semantics.ResolveSymbolAsync(arguments.Workspace(context.WorkspaceRoot), arguments.Query(), authorization, cancellationToken).ConfigureAwait(false); + return CreateTypedResult(context, request, result, ProtocolJsonContext.Default.CSharpSymbolResolutionResult, result.Status == CSharpSymbolResolutionStatus.Resolved, System.Text.Json.JsonSerializer.Serialize(result, ProtocolJsonContext.Default.CSharpSymbolResolutionResult)); + } +} diff --git a/src/SharpClaw.Code.Tools/BuiltIn/CSharpTypeHierarchyTool.cs b/src/SharpClaw.Code.Tools/BuiltIn/CSharpTypeHierarchyTool.cs new file mode 100644 index 0000000..eba56b1 --- /dev/null +++ b/src/SharpClaw.Code.Tools/BuiltIn/CSharpTypeHierarchyTool.cs @@ -0,0 +1,28 @@ +using SharpClaw.Code.Memory.Abstractions; +using SharpClaw.Code.Permissions.Abstractions; +using SharpClaw.Code.Protocol.Abstractions; +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.Telemetry.Abstractions; +using SharpClaw.Code.Tools.Execution; +using SharpClaw.Code.Tools.Models; + +namespace SharpClaw.Code.Tools.BuiltIn; + +/// Inspect base types, interfaces, derived types and implementations. +public sealed class CSharpTypeHierarchyTool(IDotNetWorkspaceSemanticService semantics, IPermissionPolicyEngine policy, IRuntimeEventPublisher? publisher = null, IRuntimeHostContextAccessor? host = null) : SharpClawToolBase +{ + /// The stable tool name. + public const string ToolName = "csharp_type_hierarchy"; + /// + public override ToolDefinition Definition { get; } = new(ToolName, "Inspect base types, interfaces, derived types and implementations.", ApprovalScope.ToolExecution, false, false, nameof(CSharpToolArguments), "Typed semantic selectors and workspace context.", ["dotnet", "semantic"], """{"type":"object","properties":{"target":{"type":"string"},"configuration":{"type":"string","default":"Debug"},"targetFramework":{"type":"string"},"name":{"type":"string"},"container":{"type":"string"},"path":{"type":"string"},"kind":{"type":"string"},"project":{"type":"string"},"signature":{"type":"string"},"line":{"type":"integer","minimum":1},"column":{"type":"integer","minimum":1},"limit":{"type":"integer","minimum":1,"maximum":200}},"required":["name"],"additionalProperties":false}"""); + /// + public override async Task ExecuteAsync(ToolExecutionContext context, ToolExecutionRequest request, CancellationToken cancellationToken) + { + var arguments = DeserializeArguments(request, ProtocolJsonContext.Default.CSharpToolArguments); + var authorization = new DotNetEvaluationAuthorization(policy, context, request, publisher, host); + var result = await semantics.GetTypeHierarchyAsync(arguments.Workspace(context.WorkspaceRoot), arguments, authorization, cancellationToken).ConfigureAwait(false); + return CreateTypedResult(context, request, result, ProtocolJsonContext.Default.CSharpTypeHierarchyResult, result.Resolution.Status == CSharpSymbolResolutionStatus.Resolved, System.Text.Json.JsonSerializer.Serialize(result, ProtocolJsonContext.Default.CSharpTypeHierarchyResult)); + } +} diff --git a/src/SharpClaw.Code.Tools/BuiltIn/DotNetSolutionInspectTool.cs b/src/SharpClaw.Code.Tools/BuiltIn/DotNetSolutionInspectTool.cs new file mode 100644 index 0000000..443e47f --- /dev/null +++ b/src/SharpClaw.Code.Tools/BuiltIn/DotNetSolutionInspectTool.cs @@ -0,0 +1,28 @@ +using SharpClaw.Code.Memory.Abstractions; +using SharpClaw.Code.Permissions.Abstractions; +using SharpClaw.Code.Protocol.Abstractions; +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.Telemetry.Abstractions; +using SharpClaw.Code.Tools.Execution; +using SharpClaw.Code.Tools.Models; + +namespace SharpClaw.Code.Tools.BuiltIn; + +/// Inspect an approved .NET solution/project graph. Cold MSBuild evaluation requires execution permission. +public sealed class DotNetSolutionInspectTool(IDotNetWorkspaceSemanticService semantics, IPermissionPolicyEngine policy, IRuntimeEventPublisher? publisher = null, IRuntimeHostContextAccessor? host = null) : SharpClawToolBase +{ + /// The stable tool name. + public const string ToolName = "dotnet_solution_inspect"; + /// + public override ToolDefinition Definition { get; } = new(ToolName, "Inspect an approved .NET solution/project graph. Cold MSBuild evaluation requires execution permission.", ApprovalScope.ToolExecution, false, false, nameof(CSharpToolArguments), "Typed semantic selectors and workspace context.", ["dotnet", "semantic"], """{"type":"object","properties":{"target":{"type":"string"},"configuration":{"type":"string","default":"Debug"},"targetFramework":{"type":"string"}},"required":[],"additionalProperties":false}"""); + /// + public override async Task ExecuteAsync(ToolExecutionContext context, ToolExecutionRequest request, CancellationToken cancellationToken) + { + var arguments = DeserializeArguments(request, ProtocolJsonContext.Default.CSharpToolArguments); + var authorization = new DotNetEvaluationAuthorization(policy, context, request, publisher, host); + var result = await semantics.InspectSolutionAsync(arguments.Workspace(context.WorkspaceRoot), authorization, cancellationToken).ConfigureAwait(false); + return CreateTypedResult(context, request, result, ProtocolJsonContext.Default.DotNetSolutionSummary, result.Status is DotNetWorkspaceStatus.Ready or DotNetWorkspaceStatus.Partial, System.Text.Json.JsonSerializer.Serialize(result, ProtocolJsonContext.Default.DotNetSolutionSummary)); + } +} diff --git a/src/SharpClaw.Code.Tools/BuiltIn/SharpClawToolBase.cs b/src/SharpClaw.Code.Tools/BuiltIn/SharpClawToolBase.cs index bf3a6b9..e44033e 100644 --- a/src/SharpClaw.Code.Tools/BuiltIn/SharpClawToolBase.cs +++ b/src/SharpClaw.Code.Tools/BuiltIn/SharpClawToolBase.cs @@ -1,3 +1,5 @@ +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; using SharpClaw.Code.Protocol.Enums; using SharpClaw.Code.Protocol.Models; using SharpClaw.Code.Tools.Abstractions; @@ -86,4 +88,16 @@ protected static ToolResult CreateFailureResult( ExitCode: exitCode, DurationMilliseconds: durationMilliseconds, StructuredOutputJson: null); + + /// Reads arguments using generated protocol metadata. + protected static T DeserializeArguments(ToolExecutionRequest request, JsonTypeInfo metadata) + => JsonSerializer.Deserialize(request.ArgumentsJson, metadata) ?? throw new JsonException("Tool arguments cannot be null."); + + /// Preserves a typed structured payload for successful and failed operations. + protected static ToolResult CreateTypedResult(ToolExecutionContext context, ToolExecutionRequest request, T payload, JsonTypeInfo metadata, bool succeeded, string text) + { + var json = JsonSerializer.Serialize(payload, metadata); + return new(request.Id, request.ToolName, succeeded, context.OutputFormat, context.OutputFormat == OutputFormat.Json ? json : text, + succeeded ? null : text, succeeded ? 0 : 1, null, json); + } } diff --git a/src/SharpClaw.Code.Tools/Execution/DotNetEvaluationAuthorization.cs b/src/SharpClaw.Code.Tools/Execution/DotNetEvaluationAuthorization.cs new file mode 100644 index 0000000..2e281a8 --- /dev/null +++ b/src/SharpClaw.Code.Tools/Execution/DotNetEvaluationAuthorization.cs @@ -0,0 +1,29 @@ +using System.Text.Json; +using SharpClaw.Code.Memory.Abstractions; +using SharpClaw.Code.Permissions.Abstractions; +using SharpClaw.Code.Permissions.Models; +using SharpClaw.Code.Protocol.Abstractions; +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Events; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.Telemetry; +using SharpClaw.Code.Telemetry.Abstractions; +using SharpClaw.Code.Tools.Models; + +namespace SharpClaw.Code.Tools.Execution; + +/// Requires a separate execution decision before MSBuild evaluation, with the original caller's policy and correlation. +internal sealed class DotNetEvaluationAuthorization(IPermissionPolicyEngine policy, ToolExecutionContext context, ToolExecutionRequest original, IRuntimeEventPublisher? publisher, IRuntimeHostContextAccessor? host) : IDotNetWorkspaceEvaluationAuthorization +{ + public async Task AuthorizeAsync(DotNetWorkspaceRequest request, CancellationToken cancellationToken) + { + var evaluation = original with { Id = original.Id + "-evaluation", ArgumentsJson = JsonSerializer.Serialize(request, ProtocolJsonContext.Default.DotNetWorkspaceRequest), ApprovalScope = ApprovalScope.ShellExecution, RequiresApproval = true, IsDestructive = false, WorkingDirectory = request.WorkspaceRoot }; + var policyContext = new PermissionEvaluationContext(context.SessionId, context.WorkspaceRoot, request.WorkspaceRoot, context.PermissionMode, context.AllowedTools, context.AllowDangerousBypass, context.IsInteractive, context.SourceKind, context.SourceName, context.TrustedPluginNames, context.TrustedMcpServerNames, PrimaryMode: context.PrimaryMode, TenantId: host?.Current?.TenantId, ApprovalSettings: context.ApprovalSettings); + var options = new RuntimeEventPublishOptions(context.WorkspaceRoot, context.SessionId, PersistToSessionStore: !string.IsNullOrWhiteSpace(context.SessionId), ThrowIfPersistenceFails: !string.IsNullOrWhiteSpace(context.SessionId)); + if (publisher is not null) await publisher.PublishAsync(new PermissionRequestedEvent("event-" + Guid.NewGuid().ToString("N"), context.SessionId, context.TurnId, DateTimeOffset.UtcNow, evaluation), options, cancellationToken).ConfigureAwait(false); + var decision = await policy.EvaluateAsync(evaluation, policyContext, cancellationToken).ConfigureAwait(false); + if (publisher is not null) await publisher.PublishAsync(new PermissionResolvedEvent("event-" + Guid.NewGuid().ToString("N"), context.SessionId, context.TurnId, DateTimeOffset.UtcNow, decision), options, cancellationToken).ConfigureAwait(false); + return decision.IsAllowed; + } +} diff --git a/src/SharpClaw.Code.Tools/Execution/ToolExecutor.cs b/src/SharpClaw.Code.Tools/Execution/ToolExecutor.cs index 1c52cfc..1d50abf 100644 --- a/src/SharpClaw.Code.Tools/Execution/ToolExecutor.cs +++ b/src/SharpClaw.Code.Tools/Execution/ToolExecutor.cs @@ -107,7 +107,7 @@ await PublishAsync( if (!permissionDecision.IsAllowed) { - var deniedResult = new ToolResult( + var deniedResult = tool is IToolPermissionDeniedResultFactory factory ? factory.CreateDeniedResult(context, request, permissionDecision) : new ToolResult( RequestId: request.Id, ToolName: request.ToolName, Succeeded: false, diff --git a/src/SharpClaw.Code.Tools/ToolsServiceCollectionExtensions.cs b/src/SharpClaw.Code.Tools/ToolsServiceCollectionExtensions.cs index 0a7d4e3..b9a731b 100644 --- a/src/SharpClaw.Code.Tools/ToolsServiceCollectionExtensions.cs +++ b/src/SharpClaw.Code.Tools/ToolsServiceCollectionExtensions.cs @@ -69,6 +69,13 @@ private static IServiceCollection AddSharpClawToolsCore(IServiceCollection servi services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(serviceProvider => new ToolSearchTool(() => serviceProvider.GetRequiredService())); diff --git a/src/SharpClaw.Code/SharpClaw.Code.csproj b/src/SharpClaw.Code/SharpClaw.Code.csproj index 9eff916..a91bf15 100644 --- a/src/SharpClaw.Code/SharpClaw.Code.csproj +++ b/src/SharpClaw.Code/SharpClaw.Code.csproj @@ -12,6 +12,7 @@ + diff --git a/tests/Fixtures/DotNetSemantic/Consumer/Consumer.csproj b/tests/Fixtures/DotNetSemantic/Consumer/Consumer.csproj new file mode 100644 index 0000000..6fcd741 --- /dev/null +++ b/tests/Fixtures/DotNetSemantic/Consumer/Consumer.csproj @@ -0,0 +1,9 @@ + + + net10.0 + 13.0 + + + + + diff --git a/tests/Fixtures/DotNetSemantic/Consumer/InvoiceConsumer.cs b/tests/Fixtures/DotNetSemantic/Consumer/InvoiceConsumer.cs new file mode 100644 index 0000000..596fbf6 --- /dev/null +++ b/tests/Fixtures/DotNetSemantic/Consumer/InvoiceConsumer.cs @@ -0,0 +1,10 @@ +using Billing; + +namespace Consumer; + +/// Consumes the cross-project invoice contract. +public sealed class InvoiceConsumer +{ + /// Calls a compiler-resolved project reference. + public string Run(IInvoiceService service) => service.Create(); +} diff --git a/tests/Fixtures/DotNetSemantic/Fixture.Tests/Fixture.Tests.csproj b/tests/Fixtures/DotNetSemantic/Fixture.Tests/Fixture.Tests.csproj new file mode 100644 index 0000000..658fbf4 --- /dev/null +++ b/tests/Fixtures/DotNetSemantic/Fixture.Tests/Fixture.Tests.csproj @@ -0,0 +1,15 @@ + + + net10.0 + 13.0 + true + false + enable + + + + + + + + diff --git a/tests/Fixtures/DotNetSemantic/Fixture.Tests/InvoiceTests.cs b/tests/Fixtures/DotNetSemantic/Fixture.Tests/InvoiceTests.cs new file mode 100644 index 0000000..35f01a2 --- /dev/null +++ b/tests/Fixtures/DotNetSemantic/Fixture.Tests/InvoiceTests.cs @@ -0,0 +1,7 @@ +using Xunit; +namespace Billing.Tests; +public sealed class InvoiceTests +{ + [Fact] + public void Invoice_has_expected_value() => Assert.Equal("invoice", new InvoiceService().Create()); +} diff --git a/tests/Fixtures/DotNetSemantic/Fixture.slnx b/tests/Fixtures/DotNetSemantic/Fixture.slnx new file mode 100644 index 0000000..184ef33 --- /dev/null +++ b/tests/Fixtures/DotNetSemantic/Fixture.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/tests/Fixtures/DotNetSemantic/Library/Invoice.cs b/tests/Fixtures/DotNetSemantic/Library/Invoice.cs new file mode 100644 index 0000000..6689673 --- /dev/null +++ b/tests/Fixtures/DotNetSemantic/Library/Invoice.cs @@ -0,0 +1,28 @@ +namespace Billing; + +/// Creates invoices. +public interface IInvoiceService +{ + /// Creates an invoice. + string Create(); +} + +/// Base invoice service. +public abstract class InvoiceBase +{ + /// Creates an invoice. + public abstract string Create(); +} + +/// Concrete invoice service. +public sealed class InvoiceService : InvoiceBase, IInvoiceService +{ + /// + public override string Create() => "invoice"; + + /// Processes an invoice by number. + public void Process(int number) { } + + /// Processes an invoice by label. + public void Process(string label) { } +} diff --git a/tests/Fixtures/DotNetSemantic/Library/Library.csproj b/tests/Fixtures/DotNetSemantic/Library/Library.csproj new file mode 100644 index 0000000..80dbd6b --- /dev/null +++ b/tests/Fixtures/DotNetSemantic/Library/Library.csproj @@ -0,0 +1,9 @@ + + + net10.0 + 13.0 + + + + + diff --git a/tests/SharpClaw.Code.IntegrationTests/Fixtures/DotNetFixtureWorkspace.cs b/tests/SharpClaw.Code.IntegrationTests/Fixtures/DotNetFixtureWorkspace.cs new file mode 100644 index 0000000..e90cf31 --- /dev/null +++ b/tests/SharpClaw.Code.IntegrationTests/Fixtures/DotNetFixtureWorkspace.cs @@ -0,0 +1,67 @@ +using SharpClaw.Code.Infrastructure.Models; +using SharpClaw.Code.Infrastructure.Services; +using SharpClaw.Code.Memory.Abstractions; +using SharpClaw.Code.Memory.Services; +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.IntegrationTests.Fixtures; + +/// Copies the checked-in fixture and explicitly provisions assets using a local empty package feed. +internal sealed class DotNetFixtureWorkspace : IDisposable +{ + internal string Root { get; } = Path.Combine(Path.GetTempPath(), "sharpclaw-dotnet-fixture-" + Guid.NewGuid().ToString("N")); + internal DotNetWorkspaceSemanticService Service { get; } + internal RecordingAuthorization Authorization { get; } = new(); + + private DotNetFixtureWorkspace() + { + var source = Path.Combine(AppContext.BaseDirectory, "DotNetFixture"); + foreach (var path in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories)) + { + var destination = Path.Combine(Root, Path.GetRelativePath(source, path)); + Directory.CreateDirectory(Path.GetDirectoryName(destination)!); + File.Copy(path, destination); + } + var fileSystem = new LocalFileSystem(); + var pathService = new PathService(); + Service = new DotNetWorkspaceSemanticService(fileSystem, pathService, new DotNetWorkspaceTargetResolver(fileSystem, pathService)); + } + + internal static async Task CreateAsync(bool includeTests = false) + { + var fixture = new DotNetFixtureWorkspace(); + try + { + if (includeTests) + { + var solution = Path.Combine(fixture.Root, "Fixture.slnx"); + await File.WriteAllTextAsync(solution, (await File.ReadAllTextAsync(solution)).Replace("", " \n", StringComparison.Ordinal)); + } + var feed = Path.Combine(fixture.Root, "empty-feed"); + Directory.CreateDirectory(feed); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + var result = await new ProcessRunner(new SystemClock()).RunAsync(new ProcessRunRequest("dotnet", ["restore", "Fixture.slnx", "--source", feed, "-p:NuGetAudit=false", "--disable-build-servers", "-m:1", "-nodeReuse:false"], fixture.Root, null), timeout.Token); + if (result.ExitCode != 0) throw new InvalidOperationException(result.StandardOutput + result.StandardError); + return fixture; + } + catch { fixture.Dispose(); throw; } + } + + public void Dispose() + { + Service.Dispose(); + if (Directory.Exists(Root)) Directory.Delete(Root, recursive: true); + } + + internal sealed class RecordingAuthorization : IDotNetWorkspaceEvaluationAuthorization + { + internal int Calls { get; private set; } + internal bool Allowed { get; set; } = true; + public Task AuthorizeAsync(DotNetWorkspaceRequest request, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + Calls++; + return Task.FromResult(Allowed); + } + } +} diff --git a/tests/SharpClaw.Code.IntegrationTests/Fixtures/FaultingFileSystem.cs b/tests/SharpClaw.Code.IntegrationTests/Fixtures/FaultingFileSystem.cs new file mode 100644 index 0000000..7c0667d --- /dev/null +++ b/tests/SharpClaw.Code.IntegrationTests/Fixtures/FaultingFileSystem.cs @@ -0,0 +1,29 @@ +using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Infrastructure.Services; + +namespace SharpClaw.Code.IntegrationTests.Fixtures; + +internal sealed class FaultingFileSystem : IFileSystem +{ + private readonly LocalFileSystem inner = new(); + internal Action? BeforeWrite { get; set; } + internal Action? BeforeRead { get; set; } + private int writes; + public Task AcquireExclusiveFileLockAsync(string path, CancellationToken cancellationToken = default) => inner.AcquireExclusiveFileLockAsync(path, cancellationToken); + public void CreateDirectory(string path) => inner.CreateDirectory(path); + public bool FileExists(string path) => inner.FileExists(path); + public bool DirectoryExists(string path) => inner.DirectoryExists(path); + public IEnumerable EnumerateDirectories(string path) => inner.EnumerateDirectories(path); + public IEnumerable EnumerateFiles(string path, string pattern) => inner.EnumerateFiles(path, pattern); + public Task ReadAllTextIfExistsAsync(string path, CancellationToken token) { BeforeRead?.Invoke(path); return inner.ReadAllTextIfExistsAsync(path, token); } + public Task ReadAllLinesIfExistsAsync(string path, CancellationToken token) => inner.ReadAllLinesIfExistsAsync(path, token); + public Task WriteAllTextAsync(string path, string content, CancellationToken token) + { + BeforeWrite?.Invoke(++writes, path, content); + return inner.WriteAllTextAsync(path, content, token); + } + public Task CopyFileAsync(string source, string destination, CancellationToken token) => inner.CopyFileAsync(source, destination, token); + public Task AppendLineAsync(string path, string line, CancellationToken token) => inner.AppendLineAsync(path, line, token); + public void DeleteDirectoryRecursive(string path) => inner.DeleteDirectoryRecursive(path); + public void TryDeleteFile(string path) => inner.TryDeleteFile(path); +} diff --git a/tests/SharpClaw.Code.IntegrationTests/Mcp/InboundMcpHttpTests.cs b/tests/SharpClaw.Code.IntegrationTests/Mcp/InboundMcpHttpTests.cs new file mode 100644 index 0000000..9a0ab01 --- /dev/null +++ b/tests/SharpClaw.Code.IntegrationTests/Mcp/InboundMcpHttpTests.cs @@ -0,0 +1,134 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using ModelContextProtocol.Client; +using SharpClaw.Code.Cli; +using SharpClaw.Code.IntegrationTests.Fixtures; +using SharpClaw.Code.Mcp.Abstractions; +using SharpClaw.Code.Mcp.Models; +using SharpClaw.Code.Mcp.Server; +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Sessions.Abstractions; + +namespace SharpClaw.Code.IntegrationTests.Mcp; + +/// Exercises the inbound MCP transport with real SDK clients. +public sealed class InboundMcpHttpTests +{ + /// Verifies permissions, protocol contracts, and transport lifecycle. + [Fact] + public async Task Loopback_http_uses_same_surface_rejects_bad_host_origin_and_malformed_requests_then_shuts_down() + { + using var fixture = await DotNetFixtureWorkspace.CreateAsync(); + await using var server = await HttpHost.StartAsync(new(fixture.Root, Transport: SharpClawMcpTransport.Http, Port: FreePort())); + using var http = new HttpClient(); + using var badHost = new HttpRequestMessage(HttpMethod.Post, server.Endpoint) { Content = new StringContent("{}", Encoding.UTF8, "application/json") }; + badHost.Headers.Host = "evil.example:" + server.Endpoint.Port; + (await http.SendAsync(badHost)).StatusCode.Should().Be(HttpStatusCode.BadRequest); + using var badOrigin = new HttpRequestMessage(HttpMethod.Post, server.Endpoint) { Content = new StringContent("{}", Encoding.UTF8, "application/json") }; + badOrigin.Headers.Add("Origin", "https://evil.example"); + (await http.SendAsync(badOrigin)).StatusCode.Should().Be(HttpStatusCode.Forbidden); + using var malformed = new HttpRequestMessage(HttpMethod.Post, server.Endpoint) { Content = new StringContent("{", Encoding.UTF8, "application/json") }; + malformed.Headers.Accept.ParseAdd("application/json"); malformed.Headers.Accept.ParseAdd("text/event-stream"); + var badResponse = await http.SendAsync(malformed); + badResponse.IsSuccessStatusCode.Should().BeFalse(); + await using var client = await ClientAsync(server.Endpoint); + (await client.ListToolsAsync()).Should().HaveCount(8); + (await client.CallToolAsync("symbol_search", new Dictionary { ["query"] = "Invoice" })).IsError.Should().NotBeTrue(); + (await client.CallToolAsync("verify_workspace")).IsError.Should().BeTrue(); + (await client.CallToolAsync("csharp_rename_symbol")).IsError.Should().BeTrue(); + } + + /// Verifies permissions, protocol contracts, and transport lifecycle. + [Fact] + public async Task Elevated_http_requires_token_and_keeps_client_durable_sessions_isolated() + { + using var fixture = await DotNetFixtureWorkspace.CreateAsync(); + const string token = "test-only-mcp-token-927354"; + await using var server = await HttpHost.StartAsync(new(fixture.Root, PermissionMode.DangerFullAccess, true, + Transport: SharpClawMcpTransport.Http, Port: FreePort(), BearerToken: token)); + using var http = new HttpClient(); + (await http.GetAsync(server.Endpoint)).StatusCode.Should().Be(HttpStatusCode.Unauthorized); + using var wrong = new HttpRequestMessage(HttpMethod.Get, server.Endpoint); + wrong.Headers.Authorization = new("Bearer", "incorrect-test-token"); + (await http.SendAsync(wrong)).StatusCode.Should().Be(HttpStatusCode.Unauthorized); + await using var first = await ClientAsync(server.Endpoint, token); + await using var second = await ClientAsync(server.Endpoint, token); + (await first.ListToolsAsync()).Should().HaveCount(9); + (await first.CallToolAsync("dotnet_solution_inspect")).IsError.Should().NotBeTrue(); + (await second.CallToolAsync("workspace_search", new Dictionary { ["query"] = "Invoice" })).IsError.Should().NotBeTrue(); + var sessions = await server.Host.Services.GetRequiredService().ListAllAsync(fixture.Root, CancellationToken.None); + sessions.Should().HaveCount(2); + sessions.Select(s => s.Id).Distinct().Should().HaveCount(2); + // Cancellation of a client request cannot poison the connection or change its selected permissions. + using var cancelled = new CancellationTokenSource(); cancelled.Cancel(); + Func call = async () => await first.CallToolAsync("verify_workspace", cancellationToken: cancelled.Token); + await call.Should().ThrowAsync(); + (await first.ListToolsAsync()).Should().HaveCount(9); + } + + /// Rejects unsafe or unauthenticated HTTP exposure. + [Theory] + [InlineData("0.0.0.0", false, null)] + [InlineData("::", true, "test-only-token-123456")] + [InlineData("192.0.2.1", false, "test-only-token-123456")] + [InlineData("192.0.2.1", true, null)] + [InlineData("localhost", false, "short")] + public void Invalid_exposure_is_rejected_before_listening(string address, bool remote, string? token) + => FluentActions.Invoking(() => SharpClawMcpServer.ValidateHttpOptions(new(".", Host: address, AllowRemote: remote, BearerToken: token))) + .Should().Throw(); + + private static Task ClientAsync(Uri endpoint, string? token = null) + => McpClient.CreateAsync(new HttpClientTransport(new HttpClientTransportOptions + { + Endpoint = endpoint, TransportMode = HttpTransportMode.StreamableHttp, + AdditionalHeaders = token is null ? null : new Dictionary { ["Authorization"] = "Bearer " + token }, + })); + + private static int FreePort() + { + using var socket = new TcpListener(IPAddress.Loopback, 0); socket.Start(); + return ((IPEndPoint)socket.LocalEndpoint).Port; + } + + private sealed class HttpHost : IAsyncDisposable + { + internal IHost Host { get; } + internal Uri Endpoint { get; } + private readonly CancellationTokenSource stop = new(); + private readonly Task run; + private HttpHost(SharpClawMcpServerOptions options) + { + Host = CliHostBuilder.BuildHost([]); + Endpoint = new Uri($"http://127.0.0.1:{options.Port}/mcp"); + run = Host.Services.GetRequiredService().RunAsync(options, stop.Token); + } + internal static async Task StartAsync(SharpClawMcpServerOptions options) + { + var server = new HttpHost(options); + try + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + using var http = new HttpClient { Timeout = TimeSpan.FromMilliseconds(250) }; + while (true) + { + if (server.run.IsCompleted) { await server.run; throw new InvalidOperationException("HTTP server stopped before listening."); } + try { using var response = await http.GetAsync(server.Endpoint, timeout.Token); break; } + catch (HttpRequestException) { await Task.Delay(50, timeout.Token); } + catch (TaskCanceledException) when (!timeout.IsCancellationRequested) { } + } + return server; + } + catch { await server.DisposeAsync(); throw; } + } + public async ValueTask DisposeAsync() + { + stop.Cancel(); + try { await run.WaitAsync(TimeSpan.FromSeconds(10)); } + finally { Host.Dispose(); stop.Dispose(); } + } + } +} diff --git a/tests/SharpClaw.Code.IntegrationTests/Mcp/InboundMcpStdioTests.cs b/tests/SharpClaw.Code.IntegrationTests/Mcp/InboundMcpStdioTests.cs new file mode 100644 index 0000000..842e875 --- /dev/null +++ b/tests/SharpClaw.Code.IntegrationTests/Mcp/InboundMcpStdioTests.cs @@ -0,0 +1,153 @@ +using System.Text.Json; +using System.Diagnostics; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using ModelContextProtocol.Client; +using SharpClaw.Code.Cli; +using SharpClaw.Code.IntegrationTests.Fixtures; +using SharpClaw.Code.Runtime; +using SharpClaw.Code.Runtime.Mutations; +using SharpClaw.Code.Sessions.Abstractions; + +namespace SharpClaw.Code.IntegrationTests.Mcp; + +/// Exercises the inbound MCP transport with real SDK clients. +public sealed class InboundMcpStdioTests +{ + /// Verifies permissions, protocol contracts, and transport lifecycle. + [Fact] + public async Task Default_server_negotiates_exposes_curated_tools_and_denies_evaluation_execution_and_hidden_rename() + { + using var fixture = await DotNetFixtureWorkspace.CreateAsync(); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(90)); + await using var client = await StartAsync(fixture.Root, [], timeout.Token); + var tools = await client.ListToolsAsync(cancellationToken: timeout.Token); + tools.Should().HaveCount(8); + tools.Should().NotContain(t => t.Name == "csharp_rename_symbol"); + tools.Single(t => t.Name == "workspace_search").JsonSchema.GetProperty("properties").GetProperty("query").GetProperty("type").GetString().Should().Be("string"); + var search = await client.CallToolAsync("workspace_search", new Dictionary { ["query"] = "Invoice" }, cancellationToken: timeout.Token); + search.IsError.Should().NotBeTrue(); + search.StructuredContent.Should().NotBeNull(); + var inspect = await client.CallToolAsync("dotnet_solution_inspect", cancellationToken: timeout.Token); + inspect.IsError.Should().BeTrue(); + inspect.StructuredContent!.Value.GetProperty("status").GetString().Should().Be("PermissionDenied"); + File.Exists(Path.Combine(fixture.Root, "Library", "evaluation-sentinel.txt")).Should().BeFalse(); + var verify = await client.CallToolAsync("verify_workspace", cancellationToken: timeout.Token); + verify.IsError.Should().BeTrue(); + verify.StructuredContent!.Value.GetProperty("reason").GetString().Should().Be("PermissionDenied"); + var rename = await client.CallToolAsync("csharp_rename_symbol", new Dictionary { ["name"] = "IInvoiceService", ["newName"] = "IBillingService" }, cancellationToken: timeout.Token); + rename.IsError.Should().BeTrue(); + } + + /// Verifies permissions, protocol contracts, and transport lifecycle. + [Fact] + public async Task Explicit_elevation_allows_semantics_and_opted_in_rename_records_an_undoable_session() + { + using var fixture = await DotNetFixtureWorkspace.CreateAsync(); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(90)); + await using (var client = await StartAsync(fixture.Root, ["--permission-mode", "dangerFullAccess", "--allow-mutations"], timeout.Token)) + { + (await client.ListToolsAsync(cancellationToken: timeout.Token)).Should().HaveCount(9); + var inspect = await client.CallToolAsync("dotnet_solution_inspect", cancellationToken: timeout.Token); + inspect.IsError.Should().NotBeTrue(); + inspect.StructuredContent!.Value.GetProperty("projects").GetArrayLength().Should().Be(2); + var rename = await client.CallToolAsync("csharp_rename_symbol", new Dictionary { ["name"] = "IInvoiceService", ["newName"] = "IBillingService" }, cancellationToken: timeout.Token); + rename.IsError.Should().NotBeTrue(rename.Content.ToString()); + } + var builder = Host.CreateApplicationBuilder(); + builder.Services.AddSharpClawRuntime(builder.Configuration); + using var host = builder.Build(); + var session = (await host.Services.GetRequiredService().GetLatestAsync(fixture.Root, timeout.Token))!; + CheckpointMutationCoordinator.ToSnapshot(session).TotalMutationSets.Should().Be(1); + var undo = await host.Services.GetRequiredService().TryUndoAsync(fixture.Root, session.Id, timeout.Token); + undo.Succeeded.Should().BeTrue(); + (await File.ReadAllTextAsync(Path.Combine(fixture.Root, "Library", "Invoice.cs"), timeout.Token)).Should().Contain("IInvoiceService").And.NotContain("IBillingService"); + } + + /// The launched CLI performs real build and test verification on an explicitly restored fixture. + [Fact] + public async Task Elevated_server_verifies_real_build_and_test_results() + { + using var fixture = await DotNetFixtureWorkspace.CreateAsync(includeTests: true); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(90)); + await using var client = await StartAsync(fixture.Root, ["--permission-mode", "dangerFullAccess"], timeout.Token); + var result = await client.CallToolAsync("verify_workspace", new Dictionary { ["scope"] = "all" }, cancellationToken: timeout.Token); + result.IsError.Should().NotBeTrue(result.Content.ToString()); + result.StructuredContent!.Value.GetProperty("status").GetString().Should().Be("Passed"); + result.StructuredContent.Value.GetProperty("tests").GetProperty("passed").GetInt32().Should().Be(1); + (await client.ListToolsAsync(cancellationToken: timeout.Token)).Should().NotContain(t => t.Name == "csharp_rename_symbol"); + } + + /// Explicit workflow modes retain their execution and mutation restrictions. + [Theory] + [InlineData("plan")] + [InlineData("research")] + public async Task Workflow_mode_restrictions_are_forwarded_to_inbound_permissions(string mode) + { + using var fixture = await DotNetFixtureWorkspace.CreateAsync(); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(60)); + await using var client = await StartAsync(fixture.Root, ["--permission-mode", "dangerFullAccess", "--allow-mutations", "--primary-mode", mode], timeout.Token); + (await client.ListToolsAsync(cancellationToken: timeout.Token)).Should().NotContain(t => t.Name == "csharp_rename_symbol"); + (await client.CallToolAsync("dotnet_solution_inspect", cancellationToken: timeout.Token)).IsError.Should().BeTrue(); + (await client.CallToolAsync("verify_workspace", cancellationToken: timeout.Token)).IsError.Should().BeTrue(); + } + + /// Every stdout line is JSON-RPC, and closing stdin cleanly stops the native or framework-dependent host. + [Fact] + public async Task Stdout_contains_only_protocol_messages_and_eof_stops_server() + { + using var fixture = await DotNetFixtureWorkspace.CreateAsync(); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(60)); + var binary = Environment.GetEnvironmentVariable("SHARPCLAW_TEST_BINARY"); + var start = new ProcessStartInfo(binary ?? "dotnet") { UseShellExecute = false, RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true, WorkingDirectory = fixture.Root }; + if (binary is null) { start.ArgumentList.Add("exec"); start.ArgumentList.Add(typeof(CliHostBuilder).Assembly.Location); } + foreach (var arg in new[] { "mcp", "serve", "--cwd", fixture.Root }) start.ArgumentList.Add(arg); + using var process = Process.Start(start)!; + var stderr = process.StandardError.ReadToEndAsync(timeout.Token); + try + { + await process.StandardInput.WriteLineAsync("""{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"wire-test","version":"1"}}}"""); + await ResponseAsync(1); + await process.StandardInput.WriteLineAsync("""{"jsonrpc":"2.0","method":"notifications/initialized"}"""); + await process.StandardInput.WriteLineAsync("""{"jsonrpc":"2.0","id":2,"method":"tools/list"}"""); + await ResponseAsync(2); + await process.StandardInput.WriteLineAsync("""{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"workspace_search","arguments":{"query":"Invoice"}}}"""); + await ResponseAsync(3); + process.StandardInput.Close(); + while (await process.StandardOutput.ReadLineAsync(timeout.Token) is { } line) { using var remaining = Validate(line); } + await process.WaitForExitAsync(timeout.Token); + process.ExitCode.Should().Be(0, await stderr); + } + finally { if (!process.HasExited) { process.Kill(entireProcessTree: true); await process.WaitForExitAsync(); } } + + async Task ResponseAsync(int id) + { + while (await process.StandardOutput.ReadLineAsync(timeout.Token) is { } line) + { + using var document = Validate(line); + if (document.RootElement.TryGetProperty("id", out var value) && value.ValueKind == JsonValueKind.Number && value.GetInt32() == id) + { document.RootElement.TryGetProperty("error", out _).Should().BeFalse(line); return; } + } + throw new InvalidOperationException("MCP stopped before replying: " + await stderr); + } + static JsonDocument Validate(string line) + { + var document = JsonDocument.Parse(line); + document.RootElement.GetProperty("jsonrpc").GetString().Should().Be("2.0"); + return document; + } + } + + internal static Task StartAsync(string root, string[] arguments, CancellationToken cancellationToken) + { + var dll = typeof(CliHostBuilder).Assembly.Location; + var binary = Environment.GetEnvironmentVariable("SHARPCLAW_TEST_BINARY"); + var transport = new StdioClientTransport(new StdioClientTransportOptions + { + Command = binary ?? "dotnet", Arguments = binary is null ? ["exec", dll, "mcp", "serve", "--cwd", root, .. arguments] : ["mcp", "serve", "--cwd", root, .. arguments], + WorkingDirectory = root, Name = "sharpclaw-test", + }); + return McpClient.CreateAsync(transport, cancellationToken: cancellationToken); + } +} diff --git a/tests/SharpClaw.Code.IntegrationTests/Memory/CSharpRenameTests.cs b/tests/SharpClaw.Code.IntegrationTests/Memory/CSharpRenameTests.cs new file mode 100644 index 0000000..193a8ae --- /dev/null +++ b/tests/SharpClaw.Code.IntegrationTests/Memory/CSharpRenameTests.cs @@ -0,0 +1,206 @@ +using System.Text.Json; +using System.Xml.Linq; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Infrastructure.Models; +using SharpClaw.Code.Infrastructure.Services; +using SharpClaw.Code.IntegrationTests.Fixtures; +using SharpClaw.Code.Memory.Abstractions; +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.Runtime; +using SharpClaw.Code.Runtime.Mutations; +using SharpClaw.Code.Runtime.Turns; +using SharpClaw.Code.Sessions.Abstractions; +using SharpClaw.Code.Tools.Abstractions; +using SharpClaw.Code.Tools.Models; + +namespace SharpClaw.Code.IntegrationTests.Memory; + +/// Checks multi-file semantic rename and real durable mutation replay. +public sealed class CSharpRenameTests +{ + /// Two successive renames compile and replay as one ordered reversible change set. + [Fact] + public async Task Semantic_rename_compiles_and_durable_undo_redo_preserves_recording_order() + { + using var fixture = await DotNetFixtureWorkspace.CreateAsync(); + using var provider = CreateProvider(fixture); + var recorder = new TurnMutationAccumulator(); + var context = Context(fixture, recorder); + var tools = provider.GetRequiredService(); + var paths = new[] { "Library/Invoice.cs", "Consumer/InvoiceConsumer.cs" }; + var original = paths.ToDictionary(path => path, path => File.ReadAllText(Path.Combine(fixture.Root, path))); + foreach (var (before, after) in new[] { ("IInvoiceService", "IBillingService"), ("IBillingService", "IAccountService") }) + { + var result = await tools.ExecuteAsync("csharp_rename_symbol", Arguments(before, after), context, CancellationToken.None); + result.Result.Succeeded.Should().BeTrue(result.Result.ErrorMessage); + } + recorder.ToSnapshot().Should().HaveCount(4); + recorder.ToSnapshot()[0].ContentAfter.Should().Be(recorder.ToSnapshot()[2].ContentBefore); + var build = await new ProcessRunner(new SystemClock()).RunAsync(new("dotnet", ["build", "Fixture.slnx", "--no-restore", "--disable-build-servers"], fixture.Root, null), CancellationToken.None); + build.ExitCode.Should().Be(0, build.StandardOutput + build.StandardError); + var session = new ConversationSession("rename-session", "Rename", SessionLifecycleState.Active, PermissionMode.WorkspaceWrite, OutputFormat.Json, fixture.Root, fixture.Root, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, null, "cp", null); + await provider.GetRequiredService().SaveAsync(fixture.Root, session, CancellationToken.None); + var coordinator = provider.GetRequiredService(); + await coordinator.ApplyRecordedMutationsAsync(fixture.Root, session, "turn", "cp", recorder.ToSnapshot(), CancellationToken.None); + (await coordinator.TryUndoAsync(fixture.Root, session.Id, CancellationToken.None)).Succeeded.Should().BeTrue(); + foreach (var path in paths) File.ReadAllText(Path.Combine(fixture.Root, path)).Should().Be(original[path]); + (await fixture.Service.ResolveSymbolAsync(new(fixture.Root), new("IInvoiceService"), null, CancellationToken.None)).Status.Should().Be(CSharpSymbolResolutionStatus.Resolved); + (await coordinator.TryRedoAsync(fixture.Root, session.Id, CancellationToken.None)).Succeeded.Should().BeTrue(); + foreach (var path in paths) File.ReadAllText(Path.Combine(fixture.Root, path)).Should().Contain("IAccountService"); + } + + /// Read-only denies writes; workspace-write permits rename on an approved snapshot. + [Fact] + public async Task Permissions_and_conflicts_write_nothing_on_denial() + { + using var fixture = await DotNetFixtureWorkspace.CreateAsync(); + using var provider = CreateProvider(fixture); + var tools = provider.GetRequiredService(); + var recorder = new TurnMutationAccumulator(); + var context = Context(fixture, recorder); + (await tools.ExecuteAsync("csharp_rename_symbol", Arguments("IInvoiceService", "IBillingService"), context with { PermissionMode = PermissionMode.ReadOnly }, CancellationToken.None)).Result.Succeeded.Should().BeFalse(); + recorder.ToSnapshot().Should().BeEmpty(); + await fixture.Service.InspectSolutionAsync(new(fixture.Root), fixture.Authorization, CancellationToken.None); + var conflict = await tools.ExecuteAsync("csharp_rename_symbol", Arguments("IInvoiceService", "InvoiceService"), context, CancellationToken.None); + conflict.Result.Succeeded.Should().BeFalse(); + File.ReadAllText(Path.Combine(fixture.Root, "Library/Invoice.cs")).Should().Contain("interface IInvoiceService"); + var success = await tools.ExecuteAsync("csharp_rename_symbol", Arguments("IInvoiceService", "IBillingService"), context with { PermissionMode = PermissionMode.WorkspaceWrite }, CancellationToken.None); + success.Result.Succeeded.Should().BeTrue(success.Result.ErrorMessage); + } + + /// Write failure rolls back; a rollback failure records the actual residual state. + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Failed_write_rolls_back_or_records_residual_mutations(bool failRollback) + { + using var fixture = await DotNetFixtureWorkspace.CreateAsync(); + var files = new FaultingFileSystem { BeforeWrite = (number, _, _) => { if (number == 2 || (failRollback && number == 3)) throw new IOException("Injected write failure"); } }; + using var provider = CreateProvider(fixture, files); + var recorder = new TurnMutationAccumulator(); + var result = await provider.GetRequiredService().ExecuteAsync("csharp_rename_symbol", Arguments("IInvoiceService", "IBillingService"), Context(fixture, recorder), CancellationToken.None); + result.Result.Succeeded.Should().BeFalse(); + var payload = JsonSerializer.Deserialize(result.Result.StructuredOutputJson!, ProtocolJsonContext.Default.CSharpRenameResult)!; + if (failRollback) + { + payload.ResidualPaths.Should().ContainSingle(); + recorder.ToSnapshot().Should().ContainSingle().Which.ContentAfter.Should().Be(File.ReadAllText(Path.Combine(fixture.Root, payload.ResidualPaths[0]))); + } + else + { + payload.ResidualPaths.Should().BeEmpty(); + recorder.ToSnapshot().Should().BeEmpty(); + File.ReadAllText(Path.Combine(fixture.Root, "Consumer/InvoiceConsumer.cs")).Should().Contain("IInvoiceService"); + } + } + + /// Cancellation uses a separate cleanup token and does not strand the first write. + [Fact] + public async Task Cancellation_rolls_back_completed_writes() + { + using var fixture = await DotNetFixtureWorkspace.CreateAsync(); + using var cancellation = new CancellationTokenSource(); + var files = new FaultingFileSystem { BeforeWrite = (number, _, _) => { if (number == 2) cancellation.Cancel(); } }; + using var provider = CreateProvider(fixture, files); + var recorder = new TurnMutationAccumulator(); + var action = () => provider.GetRequiredService().ExecuteAsync("csharp_rename_symbol", Arguments("IInvoiceService", "IBillingService"), Context(fixture, recorder), cancellation.Token); + await action.Should().ThrowAsync(); + recorder.ToSnapshot().Should().BeEmpty(); + File.ReadAllText(Path.Combine(fixture.Root, "Consumer/InvoiceConsumer.cs")).Should().Contain("IInvoiceService"); + } + + + /// Optimistic preconditions preserve concurrent external edits. + [Fact] + public async Task Concurrent_edits_are_preserved_before_first_write() + { + using var fixture = await DotNetFixtureWorkspace.CreateAsync(); + var externalPath = Path.Combine(fixture.Root, "Consumer/InvoiceConsumer.cs"); + var files = new FaultingFileSystem(); + files.BeforeRead = path => { if (path.EndsWith("InvoiceConsumer.cs", StringComparison.Ordinal)) { files.BeforeRead = null; File.AppendAllText(externalPath, "\n// external edit\n"); } }; + using var provider = CreateProvider(fixture, files); + var recorder = new TurnMutationAccumulator(); + var result = await provider.GetRequiredService().ExecuteAsync("csharp_rename_symbol", Arguments("IInvoiceService", "IBillingService"), Context(fixture, recorder), CancellationToken.None); + result.Result.Succeeded.Should().BeFalse(); + recorder.ToSnapshot().Should().BeEmpty(); + File.ReadAllText(externalPath).Should().Contain("// external edit").And.Contain("IInvoiceService"); + } + + /// Ambiguous and invalid identifiers never create a rename plan. + [Fact] + public async Task Rename_rejects_ambiguity_and_generated_sources() + { + using var fixture = await DotNetFixtureWorkspace.CreateAsync(); + var request = new DotNetWorkspaceRequest(fixture.Root); + var ambiguous = await fixture.Service.PlanRenameAsync(request, new(Name: "Process", NewName: "Handle"), fixture.Authorization, CancellationToken.None); + ambiguous.Changes.Should().BeEmpty(); + ambiguous.Resolution.Status.Should().Be(CSharpSymbolResolutionStatus.Ambiguous); + var invalid = () => fixture.Service.PlanRenameAsync(request, new(Name: "IInvoiceService", NewName: "class"), null, CancellationToken.None); + await invalid.Should().ThrowAsync(); + await File.WriteAllTextAsync(Path.Combine(fixture.Root, "Library/Generated.g.cs"), "namespace Billing; public class Generated {}"); + var generated = await fixture.Service.PlanRenameAsync(request, new(Name: "Generated", NewName: "Changed"), fixture.Authorization, CancellationToken.None); + generated.Changes.Should().BeEmpty(); + generated.Errors.Should().Contain(error => error.Contains("Generated documents", StringComparison.Ordinal)); + } + + /// A deleted residual is persisted as a reversible deletion with its original bytes. + [Fact] + public async Task Deleted_residual_can_be_restored_by_undo() + { + using var fixture = await DotNetFixtureWorkspace.CreateAsync(); + var deleted = Path.Combine(fixture.Root, "Consumer", "InvoiceConsumer.cs"); + var original = await File.ReadAllTextAsync(deleted); + var files = new FaultingFileSystem { BeforeWrite = (number, _, _) => { if (number == 2) { File.Delete(deleted); throw new IOException("Injected concurrent deletion"); } } }; + using var provider = CreateProvider(fixture, files); + var recorder = new TurnMutationAccumulator(); + var result = await provider.GetRequiredService().ExecuteAsync("csharp_rename_symbol", Arguments("IInvoiceService", "IBillingService"), Context(fixture, recorder), CancellationToken.None); + result.Result.Succeeded.Should().BeFalse(); + var operation = recorder.ToSnapshot().Single(); + operation.Kind.Should().Be(FileMutationKind.Delete); + operation.ContentAfter.Should().Be(original); + var session = new ConversationSession("deleted-residual", "Rename", SessionLifecycleState.Active, PermissionMode.WorkspaceWrite, OutputFormat.Json, fixture.Root, fixture.Root, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, null, "cp", null); + await provider.GetRequiredService().SaveAsync(fixture.Root, session, CancellationToken.None); + var coordinator = provider.GetRequiredService(); + await coordinator.ApplyRecordedMutationsAsync(fixture.Root, session, "turn", "cp", recorder.ToSnapshot(), CancellationToken.None); + (await coordinator.TryUndoAsync(fixture.Root, session.Id, CancellationToken.None)).Succeeded.Should().BeTrue(); + (await File.ReadAllTextAsync(deleted)).Should().Be(original); + } + + /// References in an external linked source reject the whole rename before mutation. + [Fact] + public async Task Rename_rejects_changed_linked_document_outside_workspace() + { + using var fixture = await DotNetFixtureWorkspace.CreateAsync(); + var external = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".cs"); + const string content = "public class ExternalConsumer { public Billing.IInvoiceService Service { get; set; } = null!; }"; + try + { + await File.WriteAllTextAsync(external, content); + var project = Path.Combine(fixture.Root, "Consumer", "Consumer.csproj"); + var xml = XDocument.Load(project); xml.Root!.Add(new XElement("ItemGroup", new XElement("Compile", new XAttribute("Include", external), new XAttribute("Link", "External.cs")))); xml.Save(project); + using var provider = CreateProvider(fixture); + var recorder = new TurnMutationAccumulator(); + var result = await provider.GetRequiredService().ExecuteAsync("csharp_rename_symbol", Arguments("IInvoiceService", "IBillingService"), Context(fixture, recorder), CancellationToken.None); + result.Result.Succeeded.Should().BeFalse(); + recorder.ToSnapshot().Should().BeEmpty(); + (await File.ReadAllTextAsync(external)).Should().Be(content); + (await File.ReadAllTextAsync(Path.Combine(fixture.Root, "Library", "Invoice.cs"))).Should().Contain("interface IInvoiceService"); + } + finally { File.Delete(external); } + } + + private static ServiceProvider CreateProvider(DotNetFixtureWorkspace fixture, IFileSystem? files = null) + { + var services = new ServiceCollection(); + services.AddSharpClawRuntime(); + services.AddSingleton(fixture.Service); + if (files is not null) services.AddSingleton(files); + return services.BuildServiceProvider(); + } + private static ToolExecutionContext Context(DotNetFixtureWorkspace fixture, TurnMutationAccumulator recorder) => new("", "", fixture.Root, fixture.Root, PermissionMode.DangerFullAccess, OutputFormat.Json, null, IsInteractive: false, MutationRecorder: recorder); + private static string Arguments(string name, string newName) => JsonSerializer.Serialize(new CSharpToolArguments(Name: name, Container: "Billing", NewName: newName), ProtocolJsonContext.Default.CSharpToolArguments); +} diff --git a/tests/SharpClaw.Code.IntegrationTests/Memory/CSharpSemanticToolTests.cs b/tests/SharpClaw.Code.IntegrationTests/Memory/CSharpSemanticToolTests.cs new file mode 100644 index 0000000..1a5828c --- /dev/null +++ b/tests/SharpClaw.Code.IntegrationTests/Memory/CSharpSemanticToolTests.cs @@ -0,0 +1,88 @@ +using System.Text.Json; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using SharpClaw.Code.IntegrationTests.Fixtures; +using SharpClaw.Code.Memory.Abstractions; +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.Tools; +using SharpClaw.Code.Tools.Abstractions; +using SharpClaw.Code.Tools.Models; + +namespace SharpClaw.Code.IntegrationTests.Memory; + +/// Exercises compiler read tools through their production registry and permissions. +public sealed class CSharpSemanticToolTests +{ + /// Cross-project references and hierarchy are compiler backed. + [Fact] + public async Task References_and_hierarchy_cross_project_boundaries() + { + using var fixture = await DotNetFixtureWorkspace.CreateAsync(); + var request = new DotNetWorkspaceRequest(fixture.Root); + var arguments = new CSharpToolArguments(Name: "IInvoiceService", Container: "Billing"); + var references = await fixture.Service.FindReferencesAsync(request, arguments, fixture.Authorization, CancellationToken.None); + references.Resolution.Status.Should().Be(CSharpSymbolResolutionStatus.Resolved); + references.References.Should().Contain(reference => reference.ProjectPath == "Consumer/Consumer.csproj" && reference.Location.Path == "Consumer/InvoiceConsumer.cs"); + var hierarchy = await fixture.Service.GetTypeHierarchyAsync(request, arguments, null, CancellationToken.None); + hierarchy.Implementations.Should().Contain(symbol => symbol.Name == "InvoiceService"); + var classes = await fixture.Service.GetTypeHierarchyAsync(request, new(Name: "InvoiceBase"), null, CancellationToken.None); + classes.DerivedTypes.Should().Contain(symbol => symbol.Name == "InvoiceService"); + var page = await fixture.Service.FindReferencesAsync(request, arguments with { Limit = 1 }, null, CancellationToken.None); + page.References.Should().ContainSingle(); + page.Truncated.Should().BeTrue(); + var ambiguous = await fixture.Service.FindReferencesAsync(request, new(Name: "Process"), null, CancellationToken.None); + ambiguous.Resolution.Status.Should().Be(CSharpSymbolResolutionStatus.Ambiguous); + ambiguous.References.Should().BeEmpty(); + } + + /// Fresh compiler diagnostics have code, location and filter support. + [Fact] + public async Task Diagnostics_refresh_source_and_filter_code_and_path() + { + using var fixture = await DotNetFixtureWorkspace.CreateAsync(); + var request = new DotNetWorkspaceRequest(fixture.Root); + await fixture.Service.InspectSolutionAsync(request, fixture.Authorization, CancellationToken.None); + var path = Path.Combine(fixture.Root, "Consumer", "InvoiceConsumer.cs"); + await File.WriteAllTextAsync(path, (await File.ReadAllTextAsync(path)).Replace("service.Create()", "service.Missing()", StringComparison.Ordinal)); + var result = await fixture.Service.GetDiagnosticsAsync(request, new(Path: "Consumer/InvoiceConsumer.cs", Severity: "error", Code: "CS1061"), null, CancellationToken.None); + result.Diagnostics.Should().ContainSingle().Which.Location!.Line.Should().BeGreaterThan(0); + result.Diagnostics[0].ProjectPath.Should().Be("Consumer/Consumer.csproj"); + var json = JsonSerializer.Serialize(result, ProtocolJsonContext.Default.CSharpDiagnosticsResult); + JsonSerializer.Deserialize(json, ProtocolJsonContext.Default.CSharpDiagnosticsResult).Should().BeEquivalentTo(result); + json.Should().NotContain("Microsoft.CodeAnalysis"); + } + + /// Tool discovery preserves explicit schemas and cold versus cached permissions. + [Fact] + public async Task Registry_executor_enforces_cold_load_and_allows_cached_read_only_queries() + { + using var fixture = await DotNetFixtureWorkspace.CreateAsync(); + var services = new ServiceCollection(); + services.AddSharpClawTools(); + services.AddSingleton(fixture.Service); + using var provider = services.BuildServiceProvider(); + var registry = provider.GetRequiredService(); + var definitions = await registry.ListAsync(fixture.Root, CancellationToken.None); + var names = new[] { "dotnet_solution_inspect", "csharp_symbol_resolve", "csharp_find_references", "csharp_type_hierarchy", "csharp_diagnostics" }; + definitions.Where(definition => names.Contains(definition.Name)).Should().HaveCount(5).And.OnlyContain(definition => definition.InputSchemaJson != null); + foreach (var definition in definitions.Where(definition => names.Contains(definition.Name))) + { + using var schema = JsonDocument.Parse(definition.InputSchemaJson!); + schema.RootElement.GetProperty("additionalProperties").GetBoolean().Should().BeFalse(); + } + var executor = provider.GetRequiredService(); + var context = new ToolExecutionContext("", "", fixture.Root, fixture.Root, PermissionMode.ReadOnly, OutputFormat.Json, null, IsInteractive: false); + var arguments = JsonSerializer.Serialize(new CSharpToolArguments(Name: "IInvoiceService"), ProtocolJsonContext.Default.CSharpToolArguments); + var denied = await executor.ExecuteAsync("csharp_symbol_resolve", arguments, context, CancellationToken.None); + denied.Result.Succeeded.Should().BeFalse(); + denied.Result.StructuredOutputJson.Should().Contain("PermissionDenied"); + var approved = await executor.ExecuteAsync("csharp_symbol_resolve", arguments, context with { PermissionMode = PermissionMode.DangerFullAccess }, CancellationToken.None); + approved.Result.Succeeded.Should().BeTrue(approved.Result.ErrorMessage); + var cached = await executor.ExecuteAsync("csharp_symbol_resolve", arguments, context, CancellationToken.None); + cached.Result.Succeeded.Should().BeTrue(cached.Result.ErrorMessage); + var restricted = await executor.ExecuteAsync("csharp_symbol_resolve", arguments, context with { AllowedTools = ["workspace_search"] }, CancellationToken.None); + restricted.Result.Succeeded.Should().BeFalse(); + } +} diff --git a/tests/SharpClaw.Code.IntegrationTests/Memory/DotNetWorkspaceSemanticTests.cs b/tests/SharpClaw.Code.IntegrationTests/Memory/DotNetWorkspaceSemanticTests.cs new file mode 100644 index 0000000..887755a --- /dev/null +++ b/tests/SharpClaw.Code.IntegrationTests/Memory/DotNetWorkspaceSemanticTests.cs @@ -0,0 +1,150 @@ +using System.Text.Json; +using FluentAssertions; +using SharpClaw.Code.IntegrationTests.Fixtures; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; + +namespace SharpClaw.Code.IntegrationTests.Memory; + +/// Exercises real, offline Roslyn solution loading and exact symbol resolution. +public sealed class DotNetWorkspaceSemanticTests +{ + /// Cold load is denied before any design time target executes. + [Fact] + public async Task Cold_load_is_denied_before_any_design_time_target_executes() + { + using var fixture = await DotNetFixtureWorkspace.CreateAsync(); + var sentinel = Path.Combine(fixture.Root, "Library", "evaluation-sentinel.txt"); + File.Delete(sentinel); + var result = await fixture.Service.InspectSolutionAsync(new(fixture.Root), null, CancellationToken.None); + result.Status.Should().Be(DotNetWorkspaceStatus.PermissionDenied); + File.Exists(sentinel).Should().BeFalse(); + } + + /// Approved load inspects graph and resolves an exact interface without analyzer execution. + [Fact] + public async Task Approved_load_inspects_graph_and_resolves_an_exact_interface_without_analyzer_execution() + { + using var fixture = await DotNetFixtureWorkspace.CreateAsync(); + var request = new DotNetWorkspaceRequest(fixture.Root); + var summary = await fixture.Service.InspectSolutionAsync(request, fixture.Authorization, CancellationToken.None); + summary.Status.Should().Be(DotNetWorkspaceStatus.Ready, string.Join(";", summary.Messages)); + summary.Projects.Should().HaveCount(2); + summary.Projects.Should().OnlyContain(project => project.TargetFrameworks.Contains("net10.0")); + summary.ProjectReferences.Should().Contain(new DotNetProjectReference("Consumer/Consumer.csproj", "Library/Library.csproj")); + File.Exists(Path.Combine(fixture.Root, "Library", "evaluation-sentinel.txt")).Should().BeTrue(); + var result = await fixture.Service.ResolveSymbolAsync(request, new("IInvoiceService", Container: "Billing", Kind: "interface"), null, CancellationToken.None); + result.Status.Should().Be(CSharpSymbolResolutionStatus.Resolved); + result.Symbol!.DocumentationId.Should().Be("T:Billing.IInvoiceService"); + result.Symbol.Locations.Should().ContainSingle().Which.Path.Should().Be("Library/Invoice.cs"); + fixture.Authorization.Calls.Should().Be(1); + var crossProject = await fixture.Service.ResolveSymbolAsync(request, new("Run", Container: "Consumer.InvoiceConsumer"), null, CancellationToken.None); + crossProject.Symbol!.DocumentationId.Should().Be("M:Consumer.InvoiceConsumer.Run(Billing.IInvoiceService)"); + var json = JsonSerializer.Serialize(result, ProtocolJsonContext.Default.CSharpSymbolResolutionResult); + JsonSerializer.Deserialize(json, ProtocolJsonContext.Default.CSharpSymbolResolutionResult).Should().BeEquivalentTo(result); + json.Should().NotContain("Microsoft.CodeAnalysis"); + } + + /// Source changes refresh cached symbols without reevaluating project code. + [Fact] + public async Task Source_changes_refresh_cached_symbols_without_reevaluating_project_code() + { + using var fixture = await DotNetFixtureWorkspace.CreateAsync(); + var request = new DotNetWorkspaceRequest(fixture.Root); + await fixture.Service.InspectSolutionAsync(request, fixture.Authorization, CancellationToken.None); + var path = Path.Combine(fixture.Root, "Library", "Invoice.cs"); + await File.WriteAllTextAsync(path, (await File.ReadAllTextAsync(path)).Replace("IInvoiceService", "IBillingService", StringComparison.Ordinal)); + var result = await fixture.Service.ResolveSymbolAsync(request, new("IBillingService"), null, CancellationToken.None); + result.Status.Should().Be(CSharpSymbolResolutionStatus.Resolved); + fixture.Authorization.Calls.Should().Be(1); + (await fixture.Service.ResolveSymbolAsync(request, new("IInvoiceService"), null, CancellationToken.None)).Status.Should().Be(CSharpSymbolResolutionStatus.NotFound); + } + + /// Changed project or compile item requires fresh authorization. + [Fact] + public async Task Changed_project_or_compile_item_requires_fresh_authorization() + { + using var fixture = await DotNetFixtureWorkspace.CreateAsync(); + var request = new DotNetWorkspaceRequest(fixture.Root); + await fixture.Service.InspectSolutionAsync(request, fixture.Authorization, CancellationToken.None); + await File.WriteAllTextAsync(Path.Combine(fixture.Root, "Library", "Added.cs"), "namespace Billing; public sealed class Added {}"); + (await fixture.Service.InspectSolutionAsync(request, null, CancellationToken.None)).Status.Should().Be(DotNetWorkspaceStatus.PermissionDenied); + (await fixture.Service.ResolveSymbolAsync(request, new("Added"), fixture.Authorization, CancellationToken.None)).Status.Should().Be(CSharpSymbolResolutionStatus.Resolved); + fixture.Authorization.Calls.Should().Be(2); + } + + /// Overloads are ambiguous until a signature selects one. + [Fact] + public async Task Overloads_are_ambiguous_until_a_signature_selects_one() + { + using var fixture = await DotNetFixtureWorkspace.CreateAsync(); + var request = new DotNetWorkspaceRequest(fixture.Root); + var result = await fixture.Service.ResolveSymbolAsync(request, new("Process", Container: "Billing.InvoiceService", Kind: "method"), fixture.Authorization, CancellationToken.None); + result.Status.Should().Be(CSharpSymbolResolutionStatus.Ambiguous); + result.Symbol.Should().BeNull(); + result.Candidates.Should().HaveCount(2); + var exact = await fixture.Service.ResolveSymbolAsync(request, new("Process", Signature: result.Candidates[0].Signature), null, CancellationToken.None); + exact.Status.Should().Be(CSharpSymbolResolutionStatus.Resolved); + } + + /// Concurrent queries share one authorized load. + [Fact] + public async Task Concurrent_queries_share_one_authorized_load() + { + using var fixture = await DotNetFixtureWorkspace.CreateAsync(); + var results = await Task.WhenAll(Enumerable.Range(0, 6).Select(_ => fixture.Service.ResolveSymbolAsync(new(fixture.Root), new("IInvoiceService"), fixture.Authorization, CancellationToken.None))); + results.Should().OnlyContain(result => result.Status == CSharpSymbolResolutionStatus.Resolved); + fixture.Authorization.Calls.Should().Be(1); + } + + /// Failed loading does not poison the cache. + [Fact] + public async Task Failed_loading_does_not_poison_the_cache() + { + using var fixture = await DotNetFixtureWorkspace.CreateAsync(); + var path = Path.Combine(fixture.Root, "Library", "Library.csproj"); + var original = await File.ReadAllTextAsync(path); + await File.WriteAllTextAsync(path, "Cache eviction requires a new load but does not dispose active queries. + [Fact] + public async Task Cache_eviction_requires_a_new_load_but_does_not_dispose_active_queries() + { + using var fixture = await DotNetFixtureWorkspace.CreateAsync(); + for (var index = 0; index < 5; index++) + { + var result = await fixture.Service.ResolveSymbolAsync(new(fixture.Root, "Library/Library.csproj", "Config" + index), new("IInvoiceService"), fixture.Authorization, CancellationToken.None); + result.Status.Should().Be(CSharpSymbolResolutionStatus.Resolved); + } + (await fixture.Service.InspectSolutionAsync(new(fixture.Root, "Library/Library.csproj", "Config0"), null, CancellationToken.None)).Status.Should().Be(DotNetWorkspaceStatus.PermissionDenied); + (await fixture.Service.ResolveSymbolAsync(new(fixture.Root, "Library/Library.csproj", "Config0"), new("IInvoiceService"), fixture.Authorization, CancellationToken.None)).Status.Should().Be(CSharpSymbolResolutionStatus.Resolved); + fixture.Authorization.Calls.Should().Be(6); + } + + /// Missing restore assets produce an explicit prerequisite result without restoring. + [Fact] + public async Task Missing_assets_are_reported_without_implicit_restore() + { + using var fixture = await DotNetFixtureWorkspace.CreateAsync(); + var assets = Path.Combine(fixture.Root, "Library", "obj", "project.assets.json"); + File.Delete(assets); + var result = await fixture.Service.InspectSolutionAsync(new(fixture.Root, "Library/Library.csproj"), fixture.Authorization, CancellationToken.None); + result.Status.Should().Be(DotNetWorkspaceStatus.RestoreRequired, string.Join(";", result.Messages)); + File.Exists(assets).Should().BeFalse(); + } + + /// Workspace SDK selection does not silently ignore an unavailable global.json version. + [Fact] + public async Task Missing_workspace_sdk_is_reported_structurally() + { + using var fixture = await DotNetFixtureWorkspace.CreateAsync(); + await File.WriteAllTextAsync(Path.Combine(fixture.Root, "global.json"), "{\"sdk\":{\"version\":\"9999.0.100\",\"rollForward\":\"disable\"}}"); + var result = await fixture.Service.InspectSolutionAsync(new(fixture.Root), fixture.Authorization, CancellationToken.None); + result.Status.Should().Be(DotNetWorkspaceStatus.SdkUnavailable, string.Join(";", result.Messages)); + } +} diff --git a/tests/SharpClaw.Code.IntegrationTests/Memory/SemanticImportCacheTests.cs b/tests/SharpClaw.Code.IntegrationTests/Memory/SemanticImportCacheTests.cs new file mode 100644 index 0000000..9c7080f --- /dev/null +++ b/tests/SharpClaw.Code.IntegrationTests/Memory/SemanticImportCacheTests.cs @@ -0,0 +1,43 @@ +using System.Xml.Linq; +using FluentAssertions; +using SharpClaw.Code.IntegrationTests.Fixtures; +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.IntegrationTests.Memory; + +/// Verifies cache invalidation for imported build files outside the root. +public sealed class SemanticImportCacheTests +{ + /// Literal external imports are fingerprinted, requiring fresh approval after changes. + [Fact] + public async Task External_literal_import_change_invalidates_approved_snapshot() + { + using var fixture = await DotNetFixtureWorkspace.CreateAsync(); + var external = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".props"); + try + { + await File.WriteAllTextAsync(external, ""); + var project = Path.Combine(fixture.Root, "Library", "Library.csproj"); + var xml = XDocument.Load(project); xml.Root!.Add(new XElement("Import", new XAttribute("Project", external))); xml.Save(project); + var request = new DotNetWorkspaceRequest(fixture.Root); + (await fixture.Service.InspectSolutionAsync(request, fixture.Authorization, CancellationToken.None)).Status.Should().Be(DotNetWorkspaceStatus.Ready); + (await fixture.Service.InspectSolutionAsync(request, null, CancellationToken.None)).Status.Should().Be(DotNetWorkspaceStatus.Ready); + await File.WriteAllTextAsync(external, "EXTERNAL_CHANGED"); + (await fixture.Service.InspectSolutionAsync(request, null, CancellationToken.None)).Status.Should().Be(DotNetWorkspaceStatus.PermissionDenied); + } + finally { File.Delete(external); } + } + + /// Property-driven imports are conservatively reauthorized rather than silently reused. + [Fact] + public async Task Property_driven_import_requires_fresh_evaluation_authorization() + { + using var fixture = await DotNetFixtureWorkspace.CreateAsync(); + var project = Path.Combine(fixture.Root, "Library", "Library.csproj"); + await File.WriteAllTextAsync(Path.Combine(fixture.Root, "Library", "Custom.props"), ""); + var xml = XDocument.Load(project); xml.Root!.Add(new XElement("PropertyGroup", new XElement("CustomProps", "Custom.props")), new XElement("Import", new XAttribute("Project", "$(CustomProps)"))); xml.Save(project); + var request = new DotNetWorkspaceRequest(fixture.Root); + (await fixture.Service.InspectSolutionAsync(request, fixture.Authorization, CancellationToken.None)).Status.Should().Be(DotNetWorkspaceStatus.Ready); + (await fixture.Service.InspectSolutionAsync(request, null, CancellationToken.None)).Status.Should().Be(DotNetWorkspaceStatus.PermissionDenied); + } +} diff --git a/tests/SharpClaw.Code.IntegrationTests/SharpClaw.Code.IntegrationTests.csproj b/tests/SharpClaw.Code.IntegrationTests/SharpClaw.Code.IntegrationTests.csproj index df45c4d..b05d1c3 100644 --- a/tests/SharpClaw.Code.IntegrationTests/SharpClaw.Code.IntegrationTests.csproj +++ b/tests/SharpClaw.Code.IntegrationTests/SharpClaw.Code.IntegrationTests.csproj @@ -17,6 +17,13 @@ + + + DotNetFixture/%(RecursiveDir)%(Filename)%(Extension) + PreserveNewest + + + diff --git a/tests/SharpClaw.Code.IntegrationTests/Verification/AutomaticVerificationTests.cs b/tests/SharpClaw.Code.IntegrationTests/Verification/AutomaticVerificationTests.cs new file mode 100644 index 0000000..ef33c81 --- /dev/null +++ b/tests/SharpClaw.Code.IntegrationTests/Verification/AutomaticVerificationTests.cs @@ -0,0 +1,198 @@ +using System.Text.Json; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using SharpClaw.Code.Agents.Abstractions; +using SharpClaw.Code.Agents.Models; +using SharpClaw.Code.Protocol.Commands; +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Events; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.Runtime; +using SharpClaw.Code.Runtime.Abstractions; +using SharpClaw.Code.Runtime.Context; +using SharpClaw.Code.Runtime.Mutations; +using SharpClaw.Code.Runtime.Turns; +using SharpClaw.Code.Runtime.Verification; +using SharpClaw.Code.Sessions.Abstractions; +using SharpClaw.Code.Tools.Models; +namespace SharpClaw.Code.IntegrationTests.Verification; + +/// Exercises logical turn accounting, exact repair budgets and durable mutation finalization. +public sealed class AutomaticVerificationTests +{ + /// Failed verification performs only the permitted repair attempts and records one reversible logical turn. + [Theory] + [InlineData(0, 1)] + [InlineData(2, 3)] + public async Task Repair_budget_is_exact_and_failed_turn_is_truthful_and_reversible(int budget, int expectedCalls) + { + using var fixture = new Fixture(); + var agent = new RecordingAgent(); + var verification = new FixedVerification(); + using var provider = Provider(agent, verification); + var runtime = provider.GetRequiredService(); + var result = await runtime.RunPromptAsync(Request(fixture.Root, budget), CancellationToken.None); + agent.Calls.Should().Be(expectedCalls); + verification.Calls.Should().Be(expectedCalls); + result.Verification!.Status.Should().Be(VerificationStatus.Failed); + result.Verification.RepairAttempts.Should().Be(budget); + result.Session.State.Should().Be(SessionLifecycleState.Failed); + result.Usage!.TotalTokens.Should().Be(5 * expectedCalls); + result.Events.OfType().Should().ContainSingle().Which.Succeeded.Should().BeFalse(); + result.Events.OfType().Should().HaveCount(expectedCalls); + result.Events.OfType().Should().HaveCount(expectedCalls); + result.Events.OfType().Should().HaveCount(budget); + var durable = await provider.GetRequiredService().ReadAllAsync(fixture.Root, result.Session.Id, CancellationToken.None); + durable.OfType().Should().HaveCount(expectedCalls); + durable.OfType().Single().Turn.Verification.Should().BeEquivalentTo(result.Verification); + var coordinator = provider.GetRequiredService(); + (await coordinator.TryUndoAsync(fixture.Root, result.Session.Id, CancellationToken.None)).Succeeded.Should().BeTrue(); + File.ReadAllText(Path.Combine(fixture.Root, "Source.cs")).Should().Be("original"); + (await coordinator.TryRedoAsync(fixture.Root, result.Session.Id, CancellationToken.None)).Succeeded.Should().BeTrue(); + File.ReadAllText(Path.Combine(fixture.Root, "Source.cs")).Should().Be("state" + expectedCalls); + if (budget > 0) agent.Contexts[1].Prompt.Should().Contain("CS1002").And.Contain("Repair attempt 1 of 2"); + } + + /// Successful repair stops immediately; disabled behavior and permission denial never start repair. + [Fact] + public async Task Successful_repair_disabled_path_and_permission_denial_are_distinct() + { + using var fixture = new Fixture(); + var agent = new RecordingAgent(); + var verification = new FixedVerification { PassAt = 2 }; + using var provider = Provider(agent, verification); + var runtime = provider.GetRequiredService(); + var repaired = await runtime.RunPromptAsync(Request(fixture.Root, 2), CancellationToken.None); + repaired.Verification!.Status.Should().Be(VerificationStatus.Passed); + agent.Calls.Should().Be(2); + repaired.Events.OfType().Single().Succeeded.Should().BeTrue(); + var disabled = Request(fixture.Root, 2) with { Metadata = new() { [SharpClawWorkflowMetadataKeys.VerificationEnabled] = "false" } }; + (await runtime.RunPromptAsync(disabled, CancellationToken.None)).Verification.Should().BeNull(); + verification.Calls.Should().Be(2); + var denied = await runtime.RunPromptAsync(Request(fixture.Root, 2) with { PermissionMode = PermissionMode.WorkspaceWrite }, CancellationToken.None); + denied.Verification!.Reason.Should().Be(VerificationFailureReason.PermissionDenied); + agent.Calls.Should().Be(4); + verification.Calls.Should().Be(2); + } + + /// Edits remain durably recoverable when the initial agent throws or execution is cancelled. + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Failed_or_cancelled_agent_preserves_mutations(bool cancelled) + { + using var fixture = new Fixture(); + using var cancellation = new CancellationTokenSource(); + var agent = new RecordingAgent { AfterWrite = () => { if (cancelled) { cancellation.Cancel(); throw new OperationCanceledException(cancellation.Token); } throw new InvalidOperationException("agent failed after editing"); } }; + using var provider = Provider(agent, new FixedVerification()); + var runtime = provider.GetRequiredService(); + var action = () => runtime.RunPromptAsync(Request(fixture.Root, 2), cancellation.Token); + await action.Should().ThrowAsync(); + var session = await provider.GetRequiredService().GetLatestAsync(fixture.Root, CancellationToken.None); + session!.State.Should().Be(SessionLifecycleState.Failed); + session.LastCheckpointId.Should().NotBeNull(); + var set = await provider.GetRequiredService().GetAsync(fixture.Root, session.Id, session.LastCheckpointId!, CancellationToken.None); + set!.Operations.Should().ContainSingle(); + (await provider.GetRequiredService().TryUndoAsync(fixture.Root, session.Id, CancellationToken.None)).Succeeded.Should().BeTrue(); + File.ReadAllText(Path.Combine(fixture.Root, "Source.cs")).Should().Be("original"); + } + + /// A same-turn restart cannot reset a consumed repair budget. + [Fact] + public async Task Durable_attempt_events_prevent_budget_reset() + { + using var fixture = new Fixture(); + var agent = new RecordingAgent(); + var verifier = new FixedVerification(); + using var provider = Provider(agent, verifier); + var runtime = provider.GetRequiredService(); + var result = await runtime.RunPromptAsync(Request(fixture.Root, 2), CancellationToken.None); + var recorder = new TurnMutationAccumulator(); + recorder.Record(new("op", FileMutationKind.Replace, "write_file", "Source.cs", "original", "state3")); + var context = agent.Contexts[0]; + var loop = await provider.GetRequiredService().RunAsync(agent, context, RecordingAgent.Result(context), recorder, new(true, MaxRepairIterations: 5), CancellationToken.None); + loop.Verification!.RepairAttempts.Should().Be(2); + agent.Calls.Should().Be(3); + verifier.Calls.Should().Be(3); + loop.Verification.Status.Should().Be(VerificationStatus.Failed); + (await provider.GetRequiredService().RunAsync(agent, context with { PrimaryMode = PrimaryMode.Plan }, RecordingAgent.Result(context), recorder, new(true), CancellationToken.None)).Verification.Should().BeNull(); + (await provider.GetRequiredService().RunAsync(agent, context, RecordingAgent.Result(context), new(), new(true), CancellationToken.None)).Verification.Should().BeNull(); + } + + + /// A failed repair retains its edits in the logical turn's checkpoint. + [Fact] + public async Task Failed_repair_agent_preserves_all_edits() + { + using var fixture = new Fixture(); + var agent = new RecordingAgent(); + agent.AfterWrite = () => { if (agent.Calls == 2) throw new InvalidOperationException("repair failed after editing"); }; + using var provider = Provider(agent, new FixedVerification()); + var result = await provider.GetRequiredService().RunPromptAsync(Request(fixture.Root, 2), CancellationToken.None); + result.Verification!.Reason.Should().Be(VerificationFailureReason.ProcessFailed); + result.Events.OfType().Should().ContainSingle().Which.Succeeded.Should().BeFalse(); + var set = await provider.GetRequiredService().GetAsync(fixture.Root, result.Session.Id, result.Checkpoint!.Id, CancellationToken.None); + set!.Operations.Should().HaveCount(2); + (await provider.GetRequiredService().TryUndoAsync(fixture.Root, result.Session.Id, CancellationToken.None)).Succeeded.Should().BeTrue(); + File.ReadAllText(Path.Combine(fixture.Root, "Source.cs")).Should().Be("original"); + } + + private static RunPromptRequest Request(string root, int budget) => new("make the change", null, root, PermissionMode.DangerFullAccess, OutputFormat.Json, + new() { [SharpClawWorkflowMetadataKeys.VerificationEnabled] = "true", [SharpClawWorkflowMetadataKeys.VerificationMaxRepairIterations] = budget.ToString() }, AgentId: "fixture-agent", IsInteractive: false); + private static ServiceProvider Provider(RecordingAgent agent, FixedVerification verification) + { + var services = new ServiceCollection(); + services.AddSharpClawRuntime(); + services.AddSingleton(agent); + services.AddSingleton(verification); + services.AddSingleton(); + return services.BuildServiceProvider(); + } + private sealed class SimplePrompt : IPromptContextAssembler + { + public Task AssembleAsync(ConversationSession session, ConversationTurn turn, RunPromptRequest request, CancellationToken cancellationToken) + => Task.FromResult(new PromptExecutionContext(request.Prompt, request.Metadata ?? new())); + } + private sealed class RecordingAgent : ISharpClawAgent + { + public string AgentId => "fixture-agent"; + public string AgentKind => "fixture"; + internal int Calls { get; private set; } + internal List Contexts { get; } = []; + internal Action? AfterWrite { get; set; } + public async Task RunAsync(AgentRunContext context, CancellationToken cancellationToken) + { + Calls++; + Contexts.Add(context); + var toolContext = new ToolExecutionContext(context.SessionId, context.TurnId, context.WorkingDirectory, context.WorkingDirectory, context.PermissionMode, context.OutputFormat, null, IsInteractive: context.IsInteractive, MutationRecorder: context.ToolMutationRecorder); + var result = await context.ToolExecutor.ExecuteAsync("write_file", JsonSerializer.Serialize(new { path = "Source.cs", content = "state" + Calls }), toolContext, cancellationToken); + result.Result.Succeeded.Should().BeTrue(result.Result.ErrorMessage); + AfterWrite?.Invoke(); + return Result(context) with { ToolResults = [result.Result] }; + } + internal static AgentRunResult Result(AgentRunContext context) + { + var request = new ProviderRequest("request-" + Guid.NewGuid(), context.SessionId, context.TurnId, "fixture", "fixture-model", context.Prompt, null, context.OutputFormat, null, null); + return new("fixture-agent", "fixture", "agent completed", new(2, 3, 0, 5, null), "completed", request, [new("event-" + Guid.NewGuid(), request.Id, "completed", DateTimeOffset.UtcNow, "done", true, new(2, 3, 0, 5, null))]); + } + } + private sealed class FixedVerification : IVerificationService + { + internal int Calls { get; private set; } + internal int PassAt { get; set; } = int.MaxValue; + public Task VerifyAsync(VerificationRequest request, CancellationToken cancellationToken) + { + Calls++; + var status = Calls >= PassAt ? VerificationStatus.Passed : VerificationStatus.Failed; + var now = DateTimeOffset.UtcNow; + return Task.FromResult(new VerificationRunReport(request.WorkspaceRoot, "Fixture.csproj", request.Scope, status, status == VerificationStatus.Passed ? VerificationFailureReason.None : VerificationFailureReason.BuildFailed, now, now, 0, ["Fixture.csproj"], new(status, [], [new("Source.cs", 1, 1, "CS1002", "error", "; expected", "Fixture.csproj")]), null, null, "fixture check")); + } + } + private sealed class Fixture : IDisposable + { + internal string Root { get; } = Path.Combine(Path.GetTempPath(), "sharpclaw-auto-" + Guid.NewGuid().ToString("N")); + internal Fixture() { Directory.CreateDirectory(Root); File.WriteAllText(Path.Combine(Root, "Source.cs"), "original"); Directory.CreateDirectory(Path.Combine(Root, ".sharpclaw")); File.WriteAllText(Path.Combine(Root, ".sharpclaw/config.jsonc"), """{"shareMode":"manual","verification":{"enabled":false}}"""); } + public void Dispose() => Directory.Delete(Root, true); + } +} diff --git a/tests/SharpClaw.Code.IntegrationTests/Verification/RealAutomaticVerificationTests.cs b/tests/SharpClaw.Code.IntegrationTests/Verification/RealAutomaticVerificationTests.cs new file mode 100644 index 0000000..e499d47 --- /dev/null +++ b/tests/SharpClaw.Code.IntegrationTests/Verification/RealAutomaticVerificationTests.cs @@ -0,0 +1,61 @@ +using System.Text.Json; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using SharpClaw.Code.Agents.Abstractions; +using SharpClaw.Code.Agents.Models; +using SharpClaw.Code.IntegrationTests.Fixtures; +using SharpClaw.Code.Memory.Abstractions; +using SharpClaw.Code.Protocol.Commands; +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Runtime; +using SharpClaw.Code.Runtime.Abstractions; +using SharpClaw.Code.Runtime.Context; +using SharpClaw.Code.Tools.Models; +namespace SharpClaw.Code.IntegrationTests.Verification; +/// Real SDK build/test repair acceptance without external model credentials. +public sealed class RealAutomaticVerificationTests +{ + /// A test-sensitive mutation is repaired and reverified within the same logical turn. + [Fact] + public async Task Test_sensitive_edit_is_repaired_and_reverified() + { + using var fixture = await DotNetFixtureWorkspace.CreateAsync(includeTests: true); + var agent = new RepairAgent(); + var services = new ServiceCollection(); + services.AddSharpClawRuntime(); + services.AddSingleton(fixture.Service); + services.AddSingleton(agent); + services.AddSingleton(); + using var provider = services.BuildServiceProvider(); + var request = new RunPromptRequest("Change the invoice implementation, keeping the test passing.", null, fixture.Root, PermissionMode.DangerFullAccess, OutputFormat.Json, + new() { [SharpClawWorkflowMetadataKeys.VerificationEnabled] = "true" }, AgentId: agent.AgentId, IsInteractive: false); + var result = await provider.GetRequiredService().RunPromptAsync(request, CancellationToken.None); + result.Verification!.Status.Should().Be(VerificationStatus.Passed, result.Verification.Summary); + result.Verification.RepairAttempts.Should().Be(1); + result.Verification.Tests!.Passed.Should().Be(1); + agent.Calls.Should().Be(2); + agent.RepairPrompt.Should().Contain("Invoice_has_expected_value"); + result.ToolResults.Should().HaveCount(4); + } + private sealed class Prompt : IPromptContextAssembler + { + public Task AssembleAsync(ConversationSession session, ConversationTurn turn, RunPromptRequest request, CancellationToken cancellationToken) => Task.FromResult(new PromptExecutionContext(request.Prompt, request.Metadata ?? new())); + } + private sealed class RepairAgent : ISharpClawAgent + { + public string AgentId => "test-repair-agent"; + public string AgentKind => "fixture"; + internal int Calls { get; private set; } + internal string? RepairPrompt { get; private set; } + public async Task RunAsync(AgentRunContext context, CancellationToken cancellationToken) + { + Calls++; + if (Calls == 2) RepairPrompt = context.Prompt; + var toolContext = new ToolExecutionContext(context.SessionId, context.TurnId, context.WorkingDirectory, context.WorkingDirectory, context.PermissionMode, OutputFormat.Json, null, IsInteractive: false, MutationRecorder: context.ToolMutationRecorder); + var result = await context.ToolExecutor.ExecuteAsync("edit_file", JsonSerializer.Serialize(new { path = "Library/Invoice.cs", oldString = Calls == 1 ? "\"invoice\"" : "\"wrong\"", newString = Calls == 1 ? "\"wrong\"" : "\"invoice\"" }), toolContext, cancellationToken); + result.Result.Succeeded.Should().BeTrue(result.Result.ErrorMessage); + return new(AgentId, AgentKind, "Updated invoice", new(1, 1, 0, 2, null), "Updated invoice", ToolResults: [result.Result]); + } + } +} diff --git a/tests/SharpClaw.Code.IntegrationTests/Verification/VerificationServiceTests.cs b/tests/SharpClaw.Code.IntegrationTests/Verification/VerificationServiceTests.cs new file mode 100644 index 0000000..2e2b6db --- /dev/null +++ b/tests/SharpClaw.Code.IntegrationTests/Verification/VerificationServiceTests.cs @@ -0,0 +1,101 @@ +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Infrastructure.Models; +using SharpClaw.Code.IntegrationTests.Fixtures; +using SharpClaw.Code.Memory.Abstractions; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Runtime; +using SharpClaw.Code.Runtime.Verification; + +namespace SharpClaw.Code.IntegrationTests.Verification; + +/// Exercises offline build/test verification without providers or implicit restore. +public sealed class VerificationServiceTests +{ + /// A real fixture builds and emits parsed TRX results, including a test-sensitive failure. + [Fact] + public async Task Real_all_verification_builds_tests_and_reports_test_failure() + { + using var fixture = await DotNetFixtureWorkspace.CreateAsync(includeTests: true); + using var provider = Provider(fixture); + var service = provider.GetRequiredService(); + var result = await service.VerifyAsync(new(fixture.Root, VerificationScope.All), CancellationToken.None); + result.Status.Should().Be(VerificationStatus.Passed, result.Summary); + result.Tests!.Total.Should().Be(1); + result.Tests.Passed.Should().Be(1); + result.Build!.Steps.Should().OnlyContain(step => step.Status == VerificationStatus.Passed); + var path = Path.Combine(fixture.Root, "Library/Invoice.cs"); + await File.WriteAllTextAsync(path, (await File.ReadAllTextAsync(path)).Replace("\"invoice\"", "\"wrong\"", StringComparison.Ordinal)); + var failed = await service.VerifyAsync(new(fixture.Root, VerificationScope.Affected, ChangedPaths: ["Library/Invoice.cs"]), CancellationToken.None); + failed.Reason.Should().Be(VerificationFailureReason.TestFailed, failed.Summary); + failed.Tests!.Cases.Should().Contain(item => item.Outcome == "Failed" && item.Message!.Contains("invoice", StringComparison.Ordinal)); + failed.AffectedProjects.Should().Contain("Fixture.Tests/Fixture.Tests.csproj"); + } + + /// Compile failures have structured locations and missing outputs/assets remain explicit prerequisites. + [Fact] + public async Task Compile_and_prerequisite_failures_are_structured() + { + using var fixture = await DotNetFixtureWorkspace.CreateAsync(includeTests: true); + using var provider = Provider(fixture); + var service = provider.GetRequiredService(); + var testOnly = await service.VerifyAsync(new(fixture.Root, VerificationScope.Tests), CancellationToken.None); + testOnly.Reason.Should().Be(VerificationFailureReason.TestOutputUnavailable); + var path = Path.Combine(fixture.Root, "Library/Invoice.cs"); + await File.AppendAllTextAsync(path, "\nthis is invalid C#;\n"); + var failed = await service.VerifyAsync(new(fixture.Root, VerificationScope.All), CancellationToken.None); + failed.Reason.Should().Be(VerificationFailureReason.BuildFailed); + failed.Build!.Diagnostics.Should().Contain(item => item.Path == "Library/Invoice.cs" && item.Line > 0); + failed.Tests.Should().BeNull(); + File.Delete(Path.Combine(fixture.Root, "Library/obj/project.assets.json")); + var missing = await service.VerifyAsync(new(fixture.Root, VerificationScope.Build), CancellationToken.None); + missing.Reason.Should().Be(VerificationFailureReason.RestoreRequired); + File.Exists(Path.Combine(fixture.Root, "Library/obj/project.assets.json")).Should().BeFalse(); + } + + /// Process exit, missing TRX and timeouts cannot masquerade as success. + [Fact] + public async Task Process_failure_missing_reports_timeout_and_no_target_are_distinct() + { + using var fixture = await DotNetFixtureWorkspace.CreateAsync(includeTests: true); + var runner = new ControlledRunner(); + using var provider = Provider(fixture, runner); + var service = provider.GetRequiredService(); + var result = await service.VerifyAsync(new(fixture.Root, VerificationScope.All), CancellationToken.None); + result.Reason.Should().Be(VerificationFailureReason.TestResultsMissing); + runner.Requests.Should().OnlyContain(request => request.Arguments.Contains("--no-restore")); + runner.Fail = true; + (await service.VerifyAsync(new(fixture.Root, VerificationScope.Build), CancellationToken.None)).Reason.Should().Be(VerificationFailureReason.BuildFailed); + runner.Fail = false; + runner.Wait = true; + (await service.VerifyAsync(new(fixture.Root, VerificationScope.Build, TimeoutSeconds: 1), CancellationToken.None)).Reason.Should().Be(VerificationFailureReason.Timeout); + var empty = Path.Combine(fixture.Root, "empty"); + Directory.CreateDirectory(empty); + (await service.VerifyAsync(new(empty), CancellationToken.None)).Status.Should().Be(VerificationStatus.Skipped); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + var action = () => service.VerifyAsync(new(fixture.Root), cancellation.Token); + await action.Should().ThrowAsync(); + } + private static ServiceProvider Provider(DotNetFixtureWorkspace fixture, IProcessRunner? runner = null) + { + var services = new ServiceCollection(); + services.AddSharpClawRuntime(); + services.AddSingleton(fixture.Service); + if (runner is not null) services.AddSingleton(runner); + return services.BuildServiceProvider(); + } + private sealed class ControlledRunner : IProcessRunner + { + internal List Requests { get; } = []; + internal bool Fail { get; set; } + internal bool Wait { get; set; } + public async Task RunAsync(ProcessRunRequest request, CancellationToken cancellationToken) + { + Requests.Add(request); + if (Wait) await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + return new(Fail ? 1 : 0, "", "", DateTimeOffset.UtcNow, DateTimeOffset.UtcNow); + } + } +} diff --git a/tests/SharpClaw.Code.IntegrationTests/Verification/VerificationSurfaceTests.cs b/tests/SharpClaw.Code.IntegrationTests/Verification/VerificationSurfaceTests.cs new file mode 100644 index 0000000..8c343e5 --- /dev/null +++ b/tests/SharpClaw.Code.IntegrationTests/Verification/VerificationSurfaceTests.cs @@ -0,0 +1,102 @@ +using System.Text.Json; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using SharpClaw.Code.Cli.Composition; +using SharpClaw.Code.Commands; +using SharpClaw.Code.Commands.Models; +using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Infrastructure.Models; +using SharpClaw.Code.IntegrationTests.Fixtures; +using SharpClaw.Code.Memory.Abstractions; +using SharpClaw.Code.Protocol.Commands; +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.Runtime; +using SharpClaw.Code.Runtime.Abstractions; +using SharpClaw.Code.Tools.Abstractions; +using SharpClaw.Code.Tools.Models; + +namespace SharpClaw.Code.IntegrationTests.Verification; + +/// Checks the common CLI, REPL, agent permission, and prompt diagnostics surfaces. +public sealed class VerificationSurfaceTests +{ + /// Read-only and noninteractive calls deny execution; explicit scoped approval permits it. + [Fact] + public async Task Tool_permissions_precede_all_process_work_and_diagnostics_never_execute() + { + using var fixture = await DotNetFixtureWorkspace.CreateAsync(); + var runner = new RecordingRunner(); + using var provider = Provider(fixture, runner); + var tools = provider.GetRequiredService(); + var context = new ToolExecutionContext("", "", fixture.Root, fixture.Root, PermissionMode.ReadOnly, OutputFormat.Json, null, IsInteractive: false); + var denied = await tools.ExecuteAsync("verify_workspace", """{"scope":"build"}""", context, CancellationToken.None); + JsonSerializer.Deserialize(denied.Result.StructuredOutputJson!, ProtocolJsonContext.Default.VerificationRunReport)!.Reason.Should().Be(VerificationFailureReason.PermissionDenied); + (await tools.ExecuteAsync("verify_workspace", "{}", context with { PermissionMode = PermissionMode.WorkspaceWrite }, CancellationToken.None)).Result.Succeeded.Should().BeFalse(); + await provider.GetRequiredService().BuildSnapshotAsync(fixture.Root, CancellationToken.None); + runner.Requests.Should().BeEmpty(); + var settings = new ApprovalSettings([ApprovalScope.ShellExecution], 1); + var allowed = await tools.ExecuteAsync("verify_workspace", """{"scope":"build"}""", context with { PermissionMode = PermissionMode.WorkspaceWrite, ApprovalSettings = settings }, CancellationToken.None); + allowed.Result.Succeeded.Should().BeTrue(allowed.Result.ErrorMessage); + runner.Requests.Should().ContainSingle(); + (await tools.ExecuteAsync("verify_workspace", """{"scope":"build"}""", context with { PermissionMode = PermissionMode.WorkspaceWrite, ApprovalSettings = settings }, CancellationToken.None)).Result.Succeeded.Should().BeFalse(); + } + + /// CLI and slash commands bind identical options and expose typed failed reports. + [Fact] + public async Task Cli_and_slash_verify_bind_shared_options_and_render_failures() + { + using var fixture = await DotNetFixtureWorkspace.CreateAsync(); + var capture = new Capture(); + using var provider = Provider(fixture, capture: capture); + var root = await provider.GetRequiredService().CreateRootCommandAsync(); + var path = Path.Combine(fixture.Root, "Library/Invoice.cs"); + await File.AppendAllTextAsync(path, "\ninvalid C# here;\n"); + var exit = await root.Parse(["--cwd", fixture.Root, "--permission-mode", "dangerFullAccess", "--output-format", "json", "verify", "--scope", "build", "--target", "Fixture.slnx"]).InvokeAsync(); + exit.Should().Be(1); + var report = JsonSerializer.Deserialize(capture.Results[^1].DataJson!, ProtocolJsonContext.Default.VerificationRunReport)!; + report.Reason.Should().Be(VerificationFailureReason.BuildFailed); + report.Build!.Diagnostics.Should().Contain(item => item.Code.StartsWith("CS", StringComparison.Ordinal) && item.Path == "Library/Invoice.cs"); + var context = new CommandExecutionContext(fixture.Root, null, PermissionMode.DangerFullAccess, OutputFormat.Json, PrimaryMode.Build); + var slash = provider.GetServices().Single(handler => handler.CommandName == "verify"); + (await slash.ExecuteAsync(new(true, "verify", ["--scope", "build", "--target", "Fixture.slnx"]), context, CancellationToken.None)).Should().Be(1); + var state = provider.GetRequiredService(); + state.PermissionModeOverride = PermissionMode.ReadOnly; + (await slash.ExecuteAsync(new(true, "verify", []), context, CancellationToken.None)).Should().Be(1); + capture.Results[^1].DataJson.Should().Contain("PermissionDenied"); + (await slash.ExecuteAsync(new(true, "verify", ["--scope", "invalid"]), context, CancellationToken.None)).Should().NotBe(0); + } + + private static ServiceProvider Provider(DotNetFixtureWorkspace fixture, IProcessRunner? runner = null, Capture? capture = null) + { + var services = new ServiceCollection(); + services.AddSharpClawRuntime(); + services.AddSharpClawCli(); + services.AddSingleton(fixture.Service); + if (runner is not null) services.AddSingleton(runner); + if (capture is not null) + { + services.RemoveAll(); + services.AddSingleton(capture); + } + return services.BuildServiceProvider(); + } + private sealed class RecordingRunner : IProcessRunner + { + internal List Requests { get; } = []; + public Task RunAsync(ProcessRunRequest request, CancellationToken cancellationToken) + { + Requests.Add(request); + return Task.FromResult(new ProcessRunResult(0, "", "", DateTimeOffset.UtcNow, DateTimeOffset.UtcNow)); + } + } + private sealed class Capture : IOutputRenderer + { + public OutputFormat Format => OutputFormat.Json; + internal List Results { get; } = []; + public Task RenderCommandResultAsync(CommandResult result, CancellationToken cancellationToken) { Results.Add(result); return Task.CompletedTask; } + public Task RenderTurnExecutionResultAsync(TurnExecutionResult result, CancellationToken cancellationToken) => Task.CompletedTask; + } +} diff --git a/tests/SharpClaw.Code.ParityHarness/ParityScenarioIds.cs b/tests/SharpClaw.Code.ParityHarness/ParityScenarioIds.cs index 94a8c09..30b7805 100644 --- a/tests/SharpClaw.Code.ParityHarness/ParityScenarioIds.cs +++ b/tests/SharpClaw.Code.ParityHarness/ParityScenarioIds.cs @@ -6,6 +6,11 @@ namespace SharpClaw.Code.ParityHarness; /// internal static class ParityScenarioIds { + public const string VerificationBuildSuccess = "verification_build_success"; + public const string VerificationBuildFailure = "verification_build_failure"; + public const string VerificationPermissionDenied = "verification_permission_denied"; + public const string SemanticRenameMutationRecorded = "semantic_rename_mutation_recorded"; + public const string SemanticSymbolResolution = "semantic_symbol_resolution"; public const string StreamingText = "streaming_text"; public const string ReadFileRoundtrip = "read_file_roundtrip"; public const string WriteFileAllowed = "write_file_allowed"; @@ -24,6 +29,11 @@ internal static class ParityScenarioIds /// public static IReadOnlyList All { get; } = [ + VerificationBuildSuccess, + VerificationBuildFailure, + VerificationPermissionDenied, + SemanticSymbolResolution, + SemanticRenameMutationRecorded, StreamingText, ReadFileRoundtrip, WriteFileAllowed, diff --git a/tests/SharpClaw.Code.ParityHarness/ParityScenarioTests.cs b/tests/SharpClaw.Code.ParityHarness/ParityScenarioTests.cs index 7e9e6fa..c00d465 100644 --- a/tests/SharpClaw.Code.ParityHarness/ParityScenarioTests.cs +++ b/tests/SharpClaw.Code.ParityHarness/ParityScenarioTests.cs @@ -379,7 +379,7 @@ public async Task Tool_call_roundtrip_executes_loop_and_returns_final_text() [Fact] public void Scenario_catalog_contains_expected_keys() { - ParityScenarioIds.All.Should().HaveCount(12); + ParityScenarioIds.All.Should().Contain(ParityScenarioIds.SemanticSymbolResolution); ParityScenarioIds.All.Should().OnlyHaveUniqueItems(); } } diff --git a/tests/SharpClaw.Code.ParityHarness/SemanticParityTests.cs b/tests/SharpClaw.Code.ParityHarness/SemanticParityTests.cs new file mode 100644 index 0000000..bed83fa --- /dev/null +++ b/tests/SharpClaw.Code.ParityHarness/SemanticParityTests.cs @@ -0,0 +1,46 @@ +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using SharpClaw.Code.IntegrationTests.Fixtures; +using SharpClaw.Code.Memory.Abstractions; +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Tools; +using SharpClaw.Code.Tools.Abstractions; +using SharpClaw.Code.Tools.Models; +using SharpClaw.Code.Runtime.Turns; + +namespace SharpClaw.Code.ParityHarness; + +/// Offline compiler semantics through production tool execution. +public sealed class SemanticParityTests +{ + /// Resolves an exact interface through the standard tool boundary. + [Fact] + public async Task Semantic_symbol_resolution() + { + using var fixture = await DotNetFixtureWorkspace.CreateAsync(); + var services = new ServiceCollection(); + services.AddSharpClawTools(); + services.AddSingleton(fixture.Service); + using var provider = services.BuildServiceProvider(); + var context = new ToolExecutionContext("", "", fixture.Root, fixture.Root, PermissionMode.DangerFullAccess, OutputFormat.Json, null, IsInteractive: false); + var result = await provider.GetRequiredService().ExecuteAsync("csharp_symbol_resolve", """{"name":"IInvoiceService","container":"Billing"}""", context, CancellationToken.None); + result.Result.Succeeded.Should().BeTrue(result.Result.ErrorMessage); + result.Result.StructuredOutputJson.Should().Contain("T:Billing.IInvoiceService"); + } + + /// Records every physical file changed by semantic rename. + [Fact] + public async Task Semantic_rename_mutation_recorded() + { + using var fixture = await DotNetFixtureWorkspace.CreateAsync(); + var services = new ServiceCollection(); + services.AddSharpClawTools(); + services.AddSingleton(fixture.Service); + using var provider = services.BuildServiceProvider(); + var recorder = new TurnMutationAccumulator(); + var context = new ToolExecutionContext("", "", fixture.Root, fixture.Root, PermissionMode.DangerFullAccess, OutputFormat.Json, null, IsInteractive: false, MutationRecorder: recorder); + var result = await provider.GetRequiredService().ExecuteAsync("csharp_rename_symbol", """{"name":"IInvoiceService","newName":"IBillingService"}""", context, CancellationToken.None); + result.Result.Succeeded.Should().BeTrue(result.Result.ErrorMessage); + recorder.ToSnapshot().Should().HaveCount(2).And.OnlyContain(operation => operation.ContentBefore!.Contains("IInvoiceService") && operation.ContentAfter.Contains("IBillingService")); + } +} diff --git a/tests/SharpClaw.Code.ParityHarness/SharpClaw.Code.ParityHarness.csproj b/tests/SharpClaw.Code.ParityHarness/SharpClaw.Code.ParityHarness.csproj index 6207347..e8ef124 100644 --- a/tests/SharpClaw.Code.ParityHarness/SharpClaw.Code.ParityHarness.csproj +++ b/tests/SharpClaw.Code.ParityHarness/SharpClaw.Code.ParityHarness.csproj @@ -30,4 +30,11 @@ + + + + DotNetFixture/%(RecursiveDir)%(Filename)%(Extension) + PreserveNewest + + \ No newline at end of file diff --git a/tests/SharpClaw.Code.ParityHarness/VerificationParityTests.cs b/tests/SharpClaw.Code.ParityHarness/VerificationParityTests.cs new file mode 100644 index 0000000..7151b30 --- /dev/null +++ b/tests/SharpClaw.Code.ParityHarness/VerificationParityTests.cs @@ -0,0 +1,44 @@ +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Infrastructure.Models; +using SharpClaw.Code.IntegrationTests.Fixtures; +using SharpClaw.Code.Memory.Abstractions; +using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Runtime; +using SharpClaw.Code.Tools.Abstractions; +using SharpClaw.Code.Tools.Models; +namespace SharpClaw.Code.ParityHarness; +/// Deterministic verification process outcomes through real permissions and tools. +public sealed class VerificationParityTests +{ + /// Build success is structured. + [Fact] + public Task Verification_build_success() => RunAsync(0, PermissionMode.DangerFullAccess, true); + /// Nonzero build exit is failure even with no diagnostics. + [Fact] + public Task Verification_build_failure() => RunAsync(1, PermissionMode.DangerFullAccess, false); + /// Read-only blocks verification before process execution. + [Fact] + public Task Verification_permission_denied() => RunAsync(0, PermissionMode.ReadOnly, false); + private static async Task RunAsync(int exit, PermissionMode mode, bool expected) + { + using var fixture = await DotNetFixtureWorkspace.CreateAsync(); + var runner = new FixedRunner(exit); + var services = new ServiceCollection(); + services.AddSharpClawRuntime(); + services.AddSingleton(fixture.Service); + services.AddSingleton(runner); + using var provider = services.BuildServiceProvider(); + var context = new ToolExecutionContext("", "", fixture.Root, fixture.Root, mode, OutputFormat.Json, null, IsInteractive: false); + var result = await provider.GetRequiredService().ExecuteAsync("verify_workspace", """{"scope":"build"}""", context, CancellationToken.None); + result.Result.Succeeded.Should().Be(expected); + result.Result.StructuredOutputJson.Should().NotBeNull(); + runner.Calls.Should().Be(mode == PermissionMode.ReadOnly ? 0 : 1); + } + private sealed class FixedRunner(int exit) : IProcessRunner + { + internal int Calls { get; private set; } + public Task RunAsync(ProcessRunRequest request, CancellationToken cancellationToken) { Calls++; return Task.FromResult(new ProcessRunResult(exit, "", "", DateTimeOffset.UtcNow, DateTimeOffset.UtcNow)); } + } +} diff --git a/tests/SharpClaw.Code.UnitTests/MemorySkillsGit/DotNetWorkspaceTargetResolverTests.cs b/tests/SharpClaw.Code.UnitTests/MemorySkillsGit/DotNetWorkspaceTargetResolverTests.cs new file mode 100644 index 0000000..cf9d700 --- /dev/null +++ b/tests/SharpClaw.Code.UnitTests/MemorySkillsGit/DotNetWorkspaceTargetResolverTests.cs @@ -0,0 +1,89 @@ +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using SharpClaw.Code.Infrastructure.Services; +using SharpClaw.Code.Memory; +using SharpClaw.Code.Memory.Abstractions; +using SharpClaw.Code.Memory.Services; +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.UnitTests.MemorySkillsGit; + +/// Verifies non-executing target selection and indexed-search independence. +public sealed class DotNetWorkspaceTargetResolverTests +{ + [Theory] + [InlineData(".sln")] + [InlineData(".slnx")] + [InlineData(".csproj")] + public async Task Single_supported_target_is_selected(string extension) + { + using var workspace = new TemporaryWorkspace(); + var target = Path.Combine(workspace.Root, "Target" + extension); + await File.WriteAllTextAsync(target, ""); + var result = await workspace.Resolver.ResolveAsync(new(workspace.Root), CancellationToken.None); + result.Status.Should().Be(DotNetWorkspaceStatus.Ready); + result.TargetPath.Should().Be(new PathService().GetCanonicalFullPath(target)); + } + + [Fact] + public async Task Multiple_targets_are_ambiguous_and_an_explicit_target_disambiguates() + { + using var workspace = new TemporaryWorkspace(); + await File.WriteAllTextAsync(Path.Combine(workspace.Root, "B.slnx"), ""); + await File.WriteAllTextAsync(Path.Combine(workspace.Root, "A.sln"), ""); + var ambiguous = await workspace.Resolver.ResolveAsync(new(workspace.Root), CancellationToken.None); + ambiguous.Status.Should().Be(DotNetWorkspaceStatus.AmbiguousTarget); + ambiguous.Candidates.Should().Equal("A.sln", "B.slnx"); + (await workspace.Resolver.ResolveAsync(new(workspace.Root, "B.slnx"), CancellationToken.None)).Status.Should().Be(DotNetWorkspaceStatus.Ready); + } + + [Fact] + public async Task A_solution_takes_precedence_over_projects_and_nested_projects_are_not_guessed() + { + using var workspace = new TemporaryWorkspace(); + Directory.CreateDirectory(Path.Combine(workspace.Root, "nested")); + await File.WriteAllTextAsync(Path.Combine(workspace.Root, "nested", "Nested.csproj"), ""); + (await workspace.Resolver.ResolveAsync(new(workspace.Root), CancellationToken.None)).Status.Should().Be(DotNetWorkspaceStatus.NoTarget); + await File.WriteAllTextAsync(Path.Combine(workspace.Root, "Fixture.csproj"), ""); + await File.WriteAllTextAsync(Path.Combine(workspace.Root, "Fixture.slnx"), ""); + (await workspace.Resolver.ResolveAsync(new(workspace.Root), CancellationToken.None)).TargetPath.Should().EndWith("Fixture.slnx"); + } + + [Fact] + public async Task Targets_outside_workspace_and_unsupported_extensions_are_rejected() + { + using var workspace = new TemporaryWorkspace(); + (await workspace.Resolver.ResolveAsync(new(workspace.Root, "../Outside.csproj"), CancellationToken.None)).Status.Should().Be(DotNetWorkspaceStatus.InvalidTarget); + await File.WriteAllTextAsync(Path.Combine(workspace.Root, "Target.txt"), ""); + (await workspace.Resolver.ResolveAsync(new(workspace.Root, "Target.txt"), CancellationToken.None)).Status.Should().Be(DotNetWorkspaceStatus.InvalidTarget); + } + + [Fact] + public async Task Cancellation_is_observed_before_target_enumeration() + { + using var workspace = new TemporaryWorkspace(); + var act = () => workspace.Resolver.ResolveAsync(new(workspace.Root), new CancellationToken(true)); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Indexed_search_does_not_load_or_evaluate_a_dotnet_workspace() + { + using var workspace = new TemporaryWorkspace(); + await File.WriteAllTextAsync(Path.Combine(workspace.Root, "Library.csproj"), "net10.0"); + await File.WriteAllTextAsync(Path.Combine(workspace.Root, "Invoice.cs"), "namespace Billing; public interface IInvoiceService {} "); + using var services = new ServiceCollection().AddSharpClawMemory().BuildServiceProvider(); + await services.GetRequiredService().RefreshAsync(workspace.Root, CancellationToken.None); + var result = await services.GetRequiredService().SearchAsync(workspace.Root, new("IInvoiceService", null), CancellationToken.None); + result.Hits.Should().NotBeEmpty(); + Directory.Exists(Path.Combine(workspace.Root, "obj")).Should().BeFalse(); + } + + private sealed class TemporaryWorkspace : IDisposable + { + internal string Root { get; } = Path.Combine(Path.GetTempPath(), "sharpclaw-target-tests-" + Guid.NewGuid().ToString("N")); + internal DotNetWorkspaceTargetResolver Resolver { get; } = new(new LocalFileSystem(), new PathService()); + internal TemporaryWorkspace() => Directory.CreateDirectory(Root); + public void Dispose() => Directory.Delete(Root, recursive: true); + } +} diff --git a/tests/SharpClaw.Code.UnitTests/Runtime/SharpClawConfigServiceTests.cs b/tests/SharpClaw.Code.UnitTests/Runtime/SharpClawConfigServiceTests.cs index 58a91e2..af70fb1 100644 --- a/tests/SharpClaw.Code.UnitTests/Runtime/SharpClawConfigServiceTests.cs +++ b/tests/SharpClaw.Code.UnitTests/Runtime/SharpClawConfigServiceTests.cs @@ -36,6 +36,7 @@ await File.WriteAllTextAsync( { // user defaults "shareMode": "Auto", + "verification": { "enabled": true, "scope": "all", "maxRepairIterations": 1 }, "defaultAgentId": "user-agent", "agents": [ { @@ -57,6 +58,7 @@ await File.WriteAllTextAsync( """ { "shareMode": "Disabled", + "verification": { "maxRepairIterations": 2 }, "defaultAgentId": "workspace-agent", "agents": [ { @@ -81,6 +83,7 @@ await File.WriteAllTextAsync( var snapshot = await service.GetConfigAsync(workspace, CancellationToken.None); snapshot.Document.ShareMode.Should().Be(ShareMode.Disabled); + snapshot.Document.Verification.Should().BeEquivalentTo(new VerificationOptions(true, VerificationScope.All, true, 2, false)); snapshot.Document.DefaultAgentId.Should().Be("workspace-agent"); snapshot.Document.ConnectLinks.Should().ContainSingle(link => link.Target == "anthropic"); snapshot.Document.Agents.Should().NotBeNull(); diff --git a/tests/SharpClaw.Code.UnitTests/Verification/VerificationParserTests.cs b/tests/SharpClaw.Code.UnitTests/Verification/VerificationParserTests.cs new file mode 100644 index 0000000..6608f45 --- /dev/null +++ b/tests/SharpClaw.Code.UnitTests/Verification/VerificationParserTests.cs @@ -0,0 +1,46 @@ +using FluentAssertions; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Runtime.Verification; + +namespace SharpClaw.Code.UnitTests.Verification; + +public sealed class VerificationParserTests +{ + [Fact] + public void Diagnostics_parse_windows_ranges_spaces_sdk_errors_and_duplicates() + { + var output = "C:\\repo name\\File.cs(12,4,12,8): error CS1002: ; expected [C:\\repo name\\App.csproj]\nMSBUILD : error MSB1009: Project file does not exist.\nerror NETSDK1004: Assets file is missing.\n"; + var result = new DotNetDiagnosticParser().Parse(output, output, "C:\\repo name"); + result.Should().HaveCount(3); + result[0].Path.Should().Be("File.cs"); + result[0].ProjectPath.Should().Be("App.csproj"); + result[0].Line.Should().Be(12); + result[1].Path.Should().BeNull(); + result[2].Code.Should().Be("NETSDK1004"); + } + + [Fact] + public void Trx_parses_namespace_and_bounded_failures_and_rejects_entities() + { + var parser = new TrxTestResultParser(); + var xml = """wrong valueat Test()"""; + var result = parser.Parse(xml, "App.Tests.csproj", "net10.0"); + result.Status.Should().Be(VerificationStatus.Failed); + result.Cases.Should().ContainSingle().Which.Message.Should().Be("wrong value"); + parser.Parse("]>&x;", "Tests", null).Reason.Should().Be(VerificationFailureReason.TestResultsMissing); + parser.Parse("", "Tests", null).Reason.Should().Be(VerificationFailureReason.TestResultsMissing); + } + + [Fact] + public void Affected_projects_follow_reverse_edges_and_fall_back_for_unknown_items() + { + DotNetProjectSummary Project(string path, string[] sources, string[] references) => new(path, path, "C#", ["net10.0"], "net10.0", path.Contains("Tests"), sources.Length, references, [], sources); + var projects = new[] { Project("Library", ["Library/File.cs"], []), Project("Consumer", ["Consumer/File.cs"], ["Library"]), Project("Tests", ["Tests/Test.cs"], ["Consumer"]), Project("Other", ["Other/File.cs"], []) }; + var solution = new DotNetSolutionSummary(DotNetWorkspaceStatus.Ready, "/workspace", "Fixture.slnx", projects, [new("Consumer", "Library"), new("Tests", "Consumer")], []); + var resolver = new AffectedProjectResolver(); + resolver.Resolve(solution, ["Library/File.cs"]).Projects.Should().BeEquivalentTo(["Library", "Consumer", "Tests"]); + resolver.Resolve(solution, ["removed.cs"]).ConservativeFallback.Should().BeTrue(); + resolver.Resolve(solution, ["Directory.Build.props"]).Projects.Should().HaveCount(4); + resolver.Resolve(solution, []).Projects.Should().HaveCount(4); + } +} diff --git a/tests/SharpClaw.Code.UnitTests/Verification/VerificationPolicyTests.cs b/tests/SharpClaw.Code.UnitTests/Verification/VerificationPolicyTests.cs new file mode 100644 index 0000000..eb350bc --- /dev/null +++ b/tests/SharpClaw.Code.UnitTests/Verification/VerificationPolicyTests.cs @@ -0,0 +1,35 @@ +using System.Text.Json; +using FluentAssertions; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Protocol.Serialization; +using SharpClaw.Code.Runtime.Abstractions; +using SharpClaw.Code.Runtime.Verification; +namespace SharpClaw.Code.UnitTests.Verification; +public sealed class VerificationPolicyTests +{ + [Fact] + public async Task Defaults_are_disabled_and_metadata_overrides_configuration() + { + var resolver = new VerificationPolicyResolver(new Configuration(new(true, VerificationScope.All, true, 1, true))); + var policy = await resolver.ResolveAsync("workspace", new Dictionary { [SharpClawWorkflowMetadataKeys.VerificationEnabled] = "false", [SharpClawWorkflowMetadataKeys.VerificationScope] = "build", [SharpClawWorkflowMetadataKeys.VerificationMaxRepairIterations] = "2", [SharpClawWorkflowMetadataKeys.VerificationAllowRestore] = "false" }, CancellationToken.None); + policy.Should().Be(new VerificationPolicy(false, VerificationScope.Build, true, 2, false)); + (await new VerificationPolicyResolver(new Configuration(null)).ResolveAsync("workspace", null, CancellationToken.None)).Should().Be(new VerificationPolicy()); + var old = JsonSerializer.Deserialize("""{"shareMode":"manual"}""", ProtocolJsonContext.Default.SharpClawConfigDocument)!; + old.Verification.Should().BeNull(); + } + [Theory] + [InlineData("sharpclaw.verification.maxRepairIterations", "-1")] + [InlineData("sharpclaw.verification.maxRepairIterations", "6")] + [InlineData("sharpclaw.verification.scope", "unknown")] + [InlineData("sharpclaw.verification.enabled", "yes")] + public async Task Invalid_metadata_is_rejected_before_agent_execution(string key, string value) + { + var resolver = new VerificationPolicyResolver(new Configuration(null)); + var action = () => resolver.ResolveAsync("workspace", new Dictionary { [key] = value }, CancellationToken.None); + await action.Should().ThrowAsync(); + } + private sealed class Configuration(VerificationOptions? options) : ISharpClawConfigService + { + public Task GetConfigAsync(string workspaceRoot, CancellationToken cancellationToken) => Task.FromResult(new SharpClawConfigSnapshot(workspaceRoot, null, null, new(ShareMode.Manual, null, null, null, null, null, null, Verification: options))); + } +} From f59733479a3c9923f744cdfed6aa521e9be71b4c Mon Sep 17 00:00:00 2001 From: telli Date: Thu, 15 Oct 2026 18:01:41 -0700 Subject: [PATCH 2/4] fix: address MCP, semantic analysis, and verification review findings --- .github/scripts/Test-Packages.ps1 | 14 +- .github/workflows/release.yml | 2 +- README.md | 2 + docs/binary-distribution.md | 2 +- docs/dotnet-intelligence.md | 10 +- docs/mcp-server.md | 4 +- ...elligence-verification-mcp-distribution.md | 7 + docs/verification.md | 4 +- .../Handlers/McpCommandHandler.cs | 6 +- .../SharpClawMcpHttpServer.cs | 49 +++++-- .../Models/SharpClawMcpServerOptions.cs | 6 +- .../Services/DotNetProjectEvaluation.cs | 49 +++++++ .../Services/DotNetSemanticWorkspaceCache.cs | 112 ++++++++++----- .../Services/DotNetWorkspaceSemanticRename.cs | 34 ++++- .../DotNetWorkspaceSemanticService.cs | 19 +-- .../Models/SharpClawWorkflowMetadataKeys.cs | 2 + .../Models/VerificationPolicyModels.cs | 4 +- .../Configuration/SharpClawConfigService.cs | 4 +- .../Verification/AffectedProjectResolver.cs | 2 +- .../IVerificationDiagnosticsCache.cs | 4 +- .../Verification/TrxTestResultParser.cs | 4 +- .../VerificationDiagnosticsCache.cs | 5 +- .../VerificationLoopCoordinator.cs | 2 +- .../VerificationPolicyResolver.cs | 6 +- .../Verification/VerificationService.cs | 45 ++++-- .../Fixtures/DotNetFixtureWorkspace.cs | 15 +- .../Fixtures/FaultingFileSystem.cs | 5 +- .../Mcp/InboundMcpHttpTests.cs | 74 ++++++++++ ...SdkMcpProcessSupervisorIntegrationTests.cs | 2 +- .../Memory/SemanticReviewRegressionTests.cs | 136 ++++++++++++++++++ .../AutomaticVerificationTests.cs | 5 +- .../Verification/VerificationServiceTests.cs | 55 ++++++- .../SharpClaw.Code.Mcp.FixtureServer.csproj | 4 +- .../Runtime/SharpClawConfigServiceTests.cs | 2 +- .../VerificationDiagnosticsCacheTests.cs | 33 +++++ .../Verification/VerificationParserTests.cs | 3 + .../Verification/VerificationPolicyTests.cs | 10 ++ 37 files changed, 631 insertions(+), 111 deletions(-) create mode 100644 src/SharpClaw.Code.Memory/Services/DotNetProjectEvaluation.cs create mode 100644 tests/SharpClaw.Code.IntegrationTests/Memory/SemanticReviewRegressionTests.cs create mode 100644 tests/SharpClaw.Code.UnitTests/Verification/VerificationDiagnosticsCacheTests.cs diff --git a/.github/scripts/Test-Packages.ps1 b/.github/scripts/Test-Packages.ps1 index af13b71..cbe0035 100644 --- a/.github/scripts/Test-Packages.ps1 +++ b/.github/scripts/Test-Packages.ps1 @@ -1,26 +1,30 @@ param( [string]$PackageVersion = "0.1.0-preview.1", - [string]$Configuration = "Release" + [string]$Configuration = "Release", + [string]$PackageSource ) $ErrorActionPreference = "Stop" $repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot "../..")).Path $scratchRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("sharpclaw-package-smoke-" + [Guid]::NewGuid().ToString("N")) -$packageOutput = Join-Path $scratchRoot "packages" +$packageOutput = if ($PackageSource) { (Resolve-Path $PackageSource).Path } else { Join-Path $scratchRoot "packages" } $toolPath = Join-Path $scratchRoot "tool" $consumerPath = Join-Path $scratchRoot "consumer" $previousNugetPackages = $env:NUGET_PACKAGES $nugetConfigPath = Join-Path $scratchRoot "NuGet.Config" try { - New-Item -ItemType Directory -Path $packageOutput -Force | Out-Null - & dotnet pack (Join-Path $repositoryRoot "SharpClawCode.Packages.slnf") --configuration $Configuration --output $packageOutput -p:PackageVersion=$PackageVersion - if ($LASTEXITCODE -ne 0) { throw "Package creation failed." } + if (!$PackageSource) { + New-Item -ItemType Directory -Path $packageOutput -Force | Out-Null + & dotnet pack (Join-Path $repositoryRoot "SharpClawCode.Packages.slnf") --configuration $Configuration --output $packageOutput -p:Version=$PackageVersion -p:PackageVersion=$PackageVersion + if ($LASTEXITCODE -ne 0) { throw "Package creation failed." } + } $packages = @(Get-ChildItem $packageOutput -Filter "*.nupkg" | Where-Object { $_.Name -notlike "*.symbols.nupkg" }) $unexpected = @($packages | Where-Object { $_.BaseName -notlike "SharpClaw.Code*" }) if ($packages.Count -ne 22) { throw "Expected 22 production packages, found $($packages.Count)." } if ($unexpected.Count -gt 0) { throw "Unexpected packages: $($unexpected.Name -join ', ')." } + if (@($packages | Where-Object { $_.Name -notlike "*.$PackageVersion.nupkg" }).Count -gt 0) { throw "Package source contains an unexpected version." } & dotnet new console --framework net10.0 --output $consumerPath --no-restore if ($LASTEXITCODE -ne 0) { throw "Could not create package smoke consumer." } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index be8c825..4a8f362 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,7 +47,7 @@ jobs: run: dotnet pack SharpClawCode.Packages.slnf --no-build --configuration Release --output ./nupkg -p:Version=${{ steps.version.outputs.value }} -p:PackageVersion=${{ steps.version.outputs.value }} - name: Verify package installability shell: pwsh - run: ./.github/scripts/Test-Packages.ps1 -PackageVersion ${{ steps.version.outputs.value }} + run: ./.github/scripts/Test-Packages.ps1 -PackageVersion ${{ steps.version.outputs.value }} -PackageSource ./nupkg - uses: actions/upload-artifact@v7 with: name: release-packages diff --git a/README.md b/README.md index a7b1bf0..03aa081 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,8 @@ It is designed for: - [.NET SDK 10](https://dotnet.microsoft.com/download/dotnet/10.0) - Optional: [GitHub CLI](https://cli.github.com/) for release and repo automation +The framework-dependent NuGet CLI tool and aggregate SDK package also require the ASP.NET Core 10 shared runtime, which is included with the .NET 10 SDK. Self-contained native archives include the required runtimes. + ### Native binary or NuGet tool Version-tag releases are configured to include native binaries for Windows, Linux, and macOS on x64 and ARM64. Extract a matching archive and run `sharpclaw version`; basic commands need no installed .NET runtime. Semantic project loading and build/test verification still require the .NET SDK and restored assets. See [binary distribution](docs/binary-distribution.md) for packaging and smoke gates. diff --git a/docs/binary-distribution.md b/docs/binary-distribution.md index 0a68f70..7679ea4 100644 --- a/docs/binary-distribution.md +++ b/docs/binary-distribution.md @@ -21,7 +21,7 @@ Extract the archive and run it directly: ./sharpclaw mcp serve --cwd ./workspace ``` -Basic commands run without an installed .NET runtime. Roslyn/MSBuild semantic loading and build/test verification require a compatible installed .NET SDK and restored project assets. Restore remains explicit. Provider-dependent prompts also need provider configuration. The NuGet CLI tool retains its normal framework-dependent installation behavior. +Basic commands run without an installed .NET runtime. Roslyn/MSBuild semantic loading and build/test verification require a compatible installed .NET SDK and restored project assets. Restore remains explicit. Provider-dependent prompts also need provider configuration. The NuGet CLI tool and aggregate SDK package are framework-dependent and require both .NET 10 and ASP.NET Core 10 shared runtimes; the .NET 10 SDK includes both. Native archives include both runtimes. The single-file bundle includes native libraries and all Roslyn helper content, extracted by the .NET host as required. Publishing is self-contained with trimming and AOT disabled. Release-only `PackAsTool=false` resolves the SDK's single-file/tool packaging conflict without changing ordinary tool packages. Assembly and informational versions receive the tag identity. diff --git a/docs/dotnet-intelligence.md b/docs/dotnet-intelligence.md index b63cd4f..92b3efb 100644 --- a/docs/dotnet-intelligence.md +++ b/docs/dotnet-intelligence.md @@ -8,23 +8,23 @@ Embedded hosts resolve `IDotNetWorkspaceTargetResolver` for non-executing discov Loading through MSBuild can execute custom design-time targets. A host must provide `IDotNetWorkspaceEvaluationAuthorization` through its execution permission policy for a cold load or structural reload. A null authorization permits valid cached queries only. Authorization is a host-side interface, never an agent-controlled Boolean in a tool argument. -Compiler queries exclude project-supplied analyzer and source-generator references. Generated-symbol support is therefore limited to source documents already materialized by approved builds. This service is not an execution sandbox. +Compiler queries retain generators from the installed .NET SDK and reference packs, including System.Text.Json, GeneratedRegex, and LoggerMessage. Trust roots come from the installed `dotnet` SDK inventory rather than project-controlled properties. Project/package-supplied analyzers and generators are excluded; results explicitly report that limitation. This service is not an execution sandbox. ## Cache and SDK behavior -The lifecycle-owned cache retains up to four compilation contexts and serializes use/load/eviction to prevent stale/disposed workspace access. Source content refresh does not reevaluate MSBuild. Changed compile-item paths, project/solution/props/targets/configuration, and restore assets invalidate loading and require fresh authorization. Literal imported build files are fingerprinted recursively, including those outside the root. Property-driven or wildcard imports require fresh evaluation authorization on each call. No filesystem watchers or hidden package restore are used. +The lifecycle-owned cache retains up to four compilation contexts and serializes use/load/eviction to prevent stale/disposed workspace access. Interactive authorization occurs outside the cache gate, allowing unrelated cached calls and shutdown to proceed while approval is pending. Source content refresh does not reevaluate MSBuild. Changed compile-item paths, project/solution/props/targets/configuration, and restore assets invalidate loading and require fresh authorization. Assets use the evaluated `ProjectAssetsFile` path, including redirected intermediate or artifacts output. Literal imported build files are fingerprinted recursively, including those outside the root. Property-driven or wildcard imports require fresh evaluation authorization on each call. Inaccessible unrelated subdirectories are skipped. Failed loads always dispose their build host. No filesystem watchers or hidden package restore are used. Roslyn 5.9 uses its bundled out-of-process build host to discover installed SDK/build tools and select the workspace SDK. A second in-process MSBuild Locator registration is unnecessary. The machine needs an appropriate .NET SDK and restored framework/package assets even when SharpClaw itself is a self-contained executable. Results use dependency-light Protocol records and generated JSON metadata. They distinguish target ambiguity, no target, permission denial, missing prerequisites, load failure, and partial loading. Symbol queries return candidates when names, overloaded signatures, or project contexts are ambiguous; selectors include container, kind, project/path, signature, and declaration position. Locations are one-based and paths are workspace-relative. -Solution inspection reports evaluated project references, available restored target frameworks/package versions, and test-project heuristics. Multi-targeted projects identify the evaluated framework; one compilation does not imply all-framework semantic coverage. +Solution inspection reports evaluated project references, available restored target frameworks/package versions, and evaluated `IsTestProject`, honoring an explicit false value. Only when that value is absent does a Microsoft.NET.Test.Sdk reference serve as a fallback. Multi-targeted projects identify the evaluated framework; one compilation does not imply all-framework semantic coverage. ## Semantic tools The standard agent tool registry exposes `dotnet_solution_inspect`, `csharp_symbol_resolve`, `csharp_find_references`, `csharp_type_hierarchy`, and `csharp_diagnostics`. Each publishes an explicit JSON input schema and generated protocol output. `name` is exact; `container`, `kind`, `project`, `path`, declaration position, and `signature` can disambiguate it. References use compiler identity across project boundaries. Hierarchy includes base types, implemented interfaces, derived types and interface implementations. Diagnostics support project, path, severity and code filters. -Use `target`, `configuration` and `targetFramework` to choose compilation context. References and diagnostics accept `offset` and `limit` (1–200); results include total/truncation metadata. Hierarchy bounds each relationship collection with `limit` and reports truncation. Source generators and project analyzers remain excluded. +Use `target`, `configuration` and `targetFramework` to choose compilation context. References and diagnostics accept `offset` and `limit` (1–200); results include total/truncation metadata. Hierarchy bounds each relationship collection with `limit` and reports truncation. SDK generators participate in source refresh and compiler diagnostics. Tool execution first applies the normal caller permissions. A cold or structurally invalidated workspace additionally asks for `ShellExecution` permission under the original tool name and caller context, publishing permission events. No tool argument can grant that permission. Read-only cold queries return `PermissionDenied`; an already approved valid snapshot supports read-only queries without evaluation. @@ -33,6 +33,8 @@ Tool execution first applies the normal caller permissions. A cold or structural `csharp_rename_symbol` selects an exact symbol and takes `newName`. Roslyn computes the complete solution edit; the tool rejects invalid identifiers, unresolved conflicts, new compiler errors, generated documents, paths outside the canonical workspace, and disagreeing linked-document edits. It does not rename files or change string/comment occurrences. +Rename validation includes SDK-generated members. Existing errors are matched by diagnostic code and mapped source location, with occurrence counts, so a renamed symbol in an error message does not itself create a new error. When custom generators are excluded, rename returns an explicit unverified error without edits; use deliberate file edits followed by build verification instead. + The tool requires `FileSystemWrite` permission and follows existing workspace-write behavior. Cold evaluation additionally requires `ShellExecution`. A workspace lock serializes semantic writes across processes. Content preconditions run before the first write and again before each write, so external edits are preserved when detected. The complete successful edit set is recorded in chronological order for normal session checkpoint undo/redo. Source-only cache refresh observes rename, undo, redo and editor changes. On failure or cancellation, rollback uses a separate bounded token. It does not overwrite a file whose content no longer matches the planned after-state. Residual changes are recorded and failed results list their paths and rollback errors. The filesystem does not provide a multi-file atomic transaction: external editors can still race between a check and a write. Recovery/undo checks content again. diff --git a/docs/mcp-server.md b/docs/mcp-server.md index 14885da..3ec7119 100644 --- a/docs/mcp-server.md +++ b/docs/mcp-server.md @@ -34,4 +34,6 @@ Connect to `http://127.0.0.1:7346/mcp`. HTTP validates the Host header and any O Set `SHARPCLAW_MCP_TOKEN` to a secret containing at least 16 non-whitespace characters before enabling elevated HTTP permissions. Clients must send `Authorization: Bearer ` on all requests. Embedded hosts can supply `BearerToken` directly. Tokens are not accepted as CLI arguments. A configured token is enforced even in read-only mode. -Remote binding requires an explicit IP, `--allow-remote`, and authentication. Wildcard hosts are rejected. Deploy remote access behind a TLS endpoint. This host-selected token uses existing tenant/storage context and scoped approvals; it does not introduce a separate identity system. +Listeners are restricted to loopback and configured explicitly, independently of workspace `appsettings.json` and Kestrel environment settings. Non-loopback plaintext binds are rejected, including with the reserved `--allow-remote` option. For remote access, run a TLS reverse proxy on the same machine and forward requests to the loopback listener. Set `--allowed-host mcp.example.com` (repeatable; embedded hosts use `AllowedHosts`) and configure the proxy to preserve that HTTPS Host authority. A nondefault HTTPS port can be included, such as `mcp.example.com:8443`. Wildcards, paths, and arbitrary forwarded headers are rejected. Allowed proxy hosts require a token even in read-only mode. Origin validation requires the matching HTTPS origin for a proxy hostname; default ports are normalized. The token therefore crosses the remote network only inside the proxy's TLS connection. + +The framework-dependent CLI tool and aggregate `SharpClaw.Code` package require the .NET 10 ASP.NET Core shared runtime because they include the HTTP MCP host. Installing the .NET 10 SDK supplies it. Self-contained native archives include this runtime. diff --git a/docs/superpowers/plans/2026-10-06-dotnet-intelligence-verification-mcp-distribution.md b/docs/superpowers/plans/2026-10-06-dotnet-intelligence-verification-mcp-distribution.md index 890ff93..e99c45e 100644 --- a/docs/superpowers/plans/2026-10-06-dotnet-intelligence-verification-mcp-distribution.md +++ b/docs/superpowers/plans/2026-10-06-dotnet-intelligence-verification-mcp-distribution.md @@ -431,4 +431,11 @@ No branches, commits, PRs, issues, or production releases should be created as p - Native acceptance completed locally for osx-arm64: a relocated three-file archive starts with empty PATH and isolated runtime roots, reports the exact preview identity, supports offline non-.NET verification and SQLite index state, and passes six real native MCP checks (default denials, elevated semantics, durable rename undo, real build/TRX tests, workflow restrictions, protocol-only stdout and clean EOF shutdown). Archive: `artifacts/binaries/sharpclaw-0.1.0-preview.1-osx-arm64.tar.gz`, with adjacent SHA-256 checksum. - Package smoke now isolates its consumer cache and maps SharpClaw package IDs to the local feed. This fixed a genuine stale same-version package false positive. All 22 production packages and 22 symbol packages were created; the new server API compiles from the aggregate SDK package and the freshly installed CLI runs. - Final gates: Release solution/example build has zero warnings/errors; 389 tests pass (275 unit, 94 integration, 20 parity). Coverage collection passed the existing 38 percent floor at 41.24 percent. All four scenario gates, DocFX with warnings as errors, vulnerability audit of 35 projects, VS Code compilation, and actionlint validation of CI/release workflows pass. Additional strict wire and linked external-source tests pass in the final suite. Generated scenario-report churn was restored; fresh results remain in ignored `artifacts/testing/`. + +### PR #37 review follow-up — 2026-10-15 + +- Addressed all fifteen review threads: isolated explicit loopback HTTP listeners from workspace/environment configuration; added authenticated HTTPS proxy host authorities and normalized default ports; aligned the stdio fixture's shared runtime and documented the framework-dependent ASP.NET Core requirement. +- Semantic loads now read evaluated test flags and restore paths, retain installed SDK generators, expose excluded custom-generator limitations, and reject unverified rename. Rename compares compiler errors by code and mapped location. Approval waits release the global cache gate while concurrent calls share one approved load; failed loads always dispose their build host, and unrelated inaccessible directories are skipped. +- Verification now serializes through the configured lock deadline, runs multi-targeted project paths once, classifies prerequisite errors by diagnostic code, preserves cached build diagnostics across test-only/prerequisite failures, and supports a validated configurable automatic timeout (600 seconds by default). Empty valid TRX results are explicitly skipped. Release package smoke accepts and checks the exact prepacked release artifacts. +- Local follow-up validation: warning-free Release solution/example build; 414 passing tests (280 unit, 114 integration, 20 parity); coverage above the existing 38 percent floor; all four agent scenarios and their gates; DocFX with warnings as errors; all 22 exact prepacked artifacts installed successfully through an isolated clean SDK consumer and CLI tool smoke. Cross-platform PR and merged-head checks remain the remote merge gates. - Release acceptance still pending externally: win-x64, win-arm64, linux-x64, linux-arm64 and osx-x64 must execute their configured matching CI jobs. No cross-publish is counted as runtime acceptance, and no packages, tag, commit, PR or GitHub release were published by this implementation run. diff --git a/docs/verification.md b/docs/verification.md index cd499f7..e0d1945 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -24,10 +24,10 @@ Prompt assembly never builds a workspace. It retains configured LSP metadata and Automatic verification is disabled by default. Add this to workspace/user JSONC to enable it: ```json -{"verification":{"enabled":true,"scope":"affected","runAfterMutatingTurn":true,"maxRepairIterations":2,"allowRestore":false}} +{"verification":{"enabled":true,"scope":"affected","runAfterMutatingTurn":true,"maxRepairIterations":2,"allowRestore":false,"timeoutSeconds":600}} ``` -Workspace fields override user fields. Request metadata overrides those defaults with `sharpclaw.verification.enabled`, `sharpclaw.verification.scope`, `sharpclaw.verification.maxRepairIterations`, and `sharpclaw.verification.allowRestore`. Invalid booleans, scopes and budgets fail validation. Budgets are 0–5; default 2 means one initial verification plus at most two agent repairs and their checks. +Workspace fields override user fields. Request metadata overrides those defaults with `sharpclaw.verification.enabled`, `sharpclaw.verification.scope`, `sharpclaw.verification.maxRepairIterations`, `sharpclaw.verification.allowRestore`, and `sharpclaw.verification.timeoutSeconds`. Invalid values fail validation. Budgets are 0–5; default 2 means one initial verification plus at most two agent repairs and their checks. Automatic checks default to 600 seconds per step and for lock acquisition, configurable from 1 to 3600 seconds. Explicit verify calls retain their 120-second default unless overridden. Lock contention waits until acquisition, request timeout, or caller cancellation. Test project classification honors evaluated `IsTestProject=false`; a valid empty TRX is reported as skipped. Each multi-targeted project is invoked once and all its TRX files are counted once. Prompt diagnostics retain the latest actual build result across test-only and prerequisite failures. The loop runs after recorded source/project/build-input mutations in build mode. Plan, spec and research modes do not trigger it. Shell, plugin and external-agent writes without mutation records are outside initial automatic detection. No recorded mutation means no automatic check. Permission denial, missing prerequisites and skipped checks do not trigger agent repair; only build/test failures do. diff --git a/src/SharpClaw.Code.Commands/Handlers/McpCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/McpCommandHandler.cs index 3db6bda..ff0ad63 100644 --- a/src/SharpClaw.Code.Commands/Handlers/McpCommandHandler.cs +++ b/src/SharpClaw.Code.Commands/Handlers/McpCommandHandler.cs @@ -47,7 +47,9 @@ private Command BuildServeCommand(GlobalCliOptions globalOptions) transport.Validators.Add(result => { if (result.GetValueOrDefault() is not ("stdio" or "http")) result.AddError("Transport must be stdio or http."); }); var host = new Option("--host") { DefaultValueFactory = _ => "127.0.0.1" }; var port = new Option("--port") { DefaultValueFactory = _ => 7346 }; - var remote = new Option("--allow-remote") { Description = "Explicitly permit authenticated non-loopback HTTP." }; + var remote = new Option("--allow-remote") { Description = "Reserved; use loopback with a local TLS reverse proxy for remote access." }; + var allowedHosts = new Option("--allowed-host") { Description = "Repeatable HTTPS DNS authority forwarded by a local TLS reverse proxy; requires a token." }; + command.Options.Add(allowedHosts); command.Options.Add(transport); command.Options.Add(host); command.Options.Add(port); command.Options.Add(remote); var mutations = new Option("--allow-mutations") { Description = "Expose rename when the host permission mode permits writes." }; command.Options.Add(mutations); @@ -63,7 +65,7 @@ await inboundServer.RunAsync(new SharpClaw.Code.Mcp.Models.SharpClawMcpServerOpt parseResult.GetValue(mutations), context.ApprovalSettings, context.HostContext, parseResult.GetValue(transport) == "http" ? SharpClaw.Code.Mcp.Models.SharpClawMcpTransport.Http : SharpClaw.Code.Mcp.Models.SharpClawMcpTransport.Stdio, parseResult.GetValue(host)!, parseResult.GetValue(port), parseResult.GetValue(remote), - Environment.GetEnvironmentVariable("SHARPCLAW_MCP_TOKEN"), context.PrimaryMode), cancellationToken).ConfigureAwait(false); + Environment.GetEnvironmentVariable("SHARPCLAW_MCP_TOKEN"), context.PrimaryMode, parseResult.GetValue(allowedHosts)), cancellationToken).ConfigureAwait(false); return 0; }); return command; diff --git a/src/SharpClaw.Code.Mcp.Server/SharpClawMcpHttpServer.cs b/src/SharpClaw.Code.Mcp.Server/SharpClawMcpHttpServer.cs index 3a6b528..2fe0f2d 100644 --- a/src/SharpClaw.Code.Mcp.Server/SharpClawMcpHttpServer.cs +++ b/src/SharpClaw.Code.Mcp.Server/SharpClawMcpHttpServer.cs @@ -17,10 +17,20 @@ public sealed partial class SharpClawMcpServer private async Task RunHttpAsync(SharpClawMcpServerOptions options, CancellationToken cancellationToken) { var endpoint = ValidateHttpOptions(options); - var builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = [], ApplicationName = typeof(SharpClawMcpServer).Assembly.GetName().Name }); - builder.WebHost.UseUrls(endpoint.AbsoluteUri.TrimEnd('/')); + // Workspace configuration and ASPNETCORE_/Kestrel__ environment settings must never select listeners. + var builder = WebApplication.CreateEmptyBuilder(new WebApplicationOptions + { + Args = [], ContentRootPath = AppContext.BaseDirectory, + ApplicationName = typeof(SharpClawMcpServer).Assembly.GetName().Name, + }); + builder.WebHost.UseKestrel().ConfigureKestrel(kestrel => + { + if (options.Host.Equals("localhost", StringComparison.OrdinalIgnoreCase)) kestrel.ListenLocalhost(options.Port); + else kestrel.Listen(IPAddress.Parse(options.Host), options.Port); + }); builder.Logging.ClearProviders(); builder.Logging.AddConsole(console => console.LogToStandardErrorThreshold = LogLevel.Trace); + builder.Services.AddRouting(); builder.Services.AddMcpServer(server => server.ServerInfo = new() { Name = "SharpClaw.Code", Version = typeof(SharpClawMcpServer).Assembly.GetName().Version?.ToString() ?? "1.0.0" }) .WithHttpTransport(transport => @@ -34,14 +44,15 @@ private async Task RunHttpAsync(SharpClawMcpServerOptions options, CancellationT await using var app = builder.Build(); app.Use(async (context, next) => { - if (!AcceptHost(context.Request.Host, endpoint)) + var accepted = AcceptedOrigin(context.Request.Host, endpoint, options.AllowedHosts); + if (accepted is null) { context.Response.StatusCode = StatusCodes.Status400BadRequest; return; } var origin = context.Request.Headers.Origin; if (origin.Count > 0 && (origin.Count != 1 || !Uri.TryCreate(origin[0], UriKind.Absolute, out var uri) - || uri.Scheme != endpoint.Scheme || uri.Authority != context.Request.Host.Value + || uri.Scheme != accepted.Scheme || !uri.Host.Equals(accepted.Host, StringComparison.OrdinalIgnoreCase) || uri.Port != accepted.Port || uri.AbsolutePath != "/" || uri.Query.Length != 0 || uri.Fragment.Length != 0 || uri.UserInfo.Length != 0)) { context.Response.StatusCode = StatusCodes.Status403Forbidden; @@ -58,7 +69,7 @@ private async Task RunHttpAsync(SharpClawMcpServerOptions options, CancellationT await app.RunAsync(cancellationToken).ConfigureAwait(false); } - /// Validates explicit HTTP exposure; elevated and remote hosts require a secret. + /// Allows loopback listeners only; authenticated remote access uses a local HTTPS reverse proxy. public static Uri ValidateHttpOptions(SharpClawMcpServerOptions options) { if (options.Port is < 1 or > 65535) throw new ArgumentOutOfRangeException(nameof(options), "HTTP port must be 1..65535."); @@ -66,21 +77,33 @@ public static Uri ValidateHttpOptions(SharpClawMcpServerOptions options) if (!localhost && (!IPAddress.TryParse(options.Host, out var address) || address.Equals(IPAddress.Any) || address.Equals(IPAddress.IPv6Any))) throw new ArgumentException("HTTP host must be localhost or an explicit non-wildcard IP address.", nameof(options)); var loopback = localhost || IPAddress.IsLoopback(IPAddress.Parse(options.Host)); - if (!loopback && !options.AllowRemote) throw new ArgumentException("Non-loopback HTTP requires AllowRemote.", nameof(options)); - if ((!loopback || options.PermissionMode != PermissionMode.ReadOnly) && string.IsNullOrWhiteSpace(options.BearerToken)) - throw new ArgumentException("Remote or elevated HTTP requires SHARPCLAW_MCP_TOKEN or a host-supplied BearerToken.", nameof(options)); + if (!loopback) throw new ArgumentException("HTTP listeners must be loopback. Use a local TLS reverse proxy and AllowedHosts for remote access.", nameof(options)); + foreach (var host in options.AllowedHosts ?? []) + { + if (!Uri.TryCreate("https://" + host, UriKind.Absolute, out var proxy) || proxy.HostNameType != UriHostNameType.Dns + || proxy.IsLoopback || proxy.AbsolutePath != "/" || proxy.Query.Length != 0 || proxy.Fragment.Length != 0 || proxy.UserInfo.Length != 0 + || proxy.Authority != host.ToLowerInvariant()) + throw new ArgumentException("AllowedHosts must contain HTTPS DNS authorities without paths or explicit default ports.", nameof(options)); + } + if ((options.AllowedHosts is { Length: > 0 } || options.PermissionMode != PermissionMode.ReadOnly) && string.IsNullOrWhiteSpace(options.BearerToken)) + throw new ArgumentException("Proxied or elevated HTTP requires SHARPCLAW_MCP_TOKEN or a host-supplied BearerToken.", nameof(options)); if (options.BearerToken is not null && (options.BearerToken.Length < 16 || options.BearerToken.Any(char.IsWhiteSpace))) throw new ArgumentException("HTTP bearer token must contain at least 16 characters without whitespace.", nameof(options)); return new UriBuilder("http", options.Host, options.Port).Uri; } - private static bool AcceptHost(HostString host, Uri endpoint) + private static Uri? AcceptedOrigin(HostString host, Uri endpoint, string[]? allowedHosts) { - if (host.Port != endpoint.Port) return false; var name = host.Host.Trim('[', ']'); - if (endpoint.IsLoopback) return name.Equals("localhost", StringComparison.OrdinalIgnoreCase) - || IPAddress.TryParse(name, out var address) && IPAddress.IsLoopback(address); - return name.Equals(endpoint.Host.Trim('[', ']'), StringComparison.OrdinalIgnoreCase); + if ((host.Port ?? 80) == endpoint.Port && (name.Equals("localhost", StringComparison.OrdinalIgnoreCase) + || IPAddress.TryParse(name, out var address) && IPAddress.IsLoopback(address))) + return new UriBuilder("http", name, endpoint.Port).Uri; + foreach (var allowed in allowedHosts ?? []) + { + var proxy = new Uri("https://" + allowed); + if (name.Equals(proxy.Host, StringComparison.OrdinalIgnoreCase) && (host.Port ?? 443) == proxy.Port) return proxy; + } + return null; } private static bool HasToken(HttpRequest request, string expected) diff --git a/src/SharpClaw.Code.Mcp/Models/SharpClawMcpServerOptions.cs b/src/SharpClaw.Code.Mcp/Models/SharpClawMcpServerOptions.cs index 4595dff..df455e9 100644 --- a/src/SharpClaw.Code.Mcp/Models/SharpClawMcpServerOptions.cs +++ b/src/SharpClaw.Code.Mcp/Models/SharpClawMcpServerOptions.cs @@ -12,9 +12,10 @@ namespace SharpClaw.Code.Mcp.Models; /// Stdio or Streamable HTTP. /// Explicit IP address or localhost; wildcard binding is rejected. /// HTTP listen port. -/// Explicit authorization for non-loopback exposure. +/// Reserved for compatibility; non-loopback plaintext listeners are rejected. /// Authentication secret for remote or elevated HTTP, supplied by the host. /// Host-selected workflow restrictions forwarded to the permission policy. +/// HTTPS DNS authorities forwarded by a local TLS reverse proxy; requires authentication. public sealed record SharpClawMcpServerOptions( string WorkspaceRoot, PermissionMode PermissionMode = PermissionMode.ReadOnly, @@ -26,7 +27,8 @@ public sealed record SharpClawMcpServerOptions( int Port = 7346, bool AllowRemote = false, string? BearerToken = null, - PrimaryMode PrimaryMode = PrimaryMode.Build); + PrimaryMode PrimaryMode = PrimaryMode.Build, + string[]? AllowedHosts = null); /// The supported inbound transport choices. public enum SharpClawMcpTransport diff --git a/src/SharpClaw.Code.Memory/Services/DotNetProjectEvaluation.cs b/src/SharpClaw.Code.Memory/Services/DotNetProjectEvaluation.cs new file mode 100644 index 0000000..bf4379f --- /dev/null +++ b/src/SharpClaw.Code.Memory/Services/DotNetProjectEvaluation.cs @@ -0,0 +1,49 @@ +using System.Text.Json; +using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Infrastructure.Models; + +namespace SharpClaw.Code.Memory.Services; + +/// Reads evaluated SDK properties only during an authorized load; never invokes restore or build targets. +internal sealed record DotNetProjectEvaluation(string AssetsPath, bool? IsTestProject, string[] TargetFrameworks) +{ + internal static async Task ReadAsync(IProcessRunner processes, string projectPath, string configuration, string? framework, CancellationToken cancellationToken) + { + var arguments = new List + { + "msbuild", projectPath, "-nologo", "-nodeReuse:false", + "-getProperty:ProjectAssetsFile,IsTestProject,TargetFramework,TargetFrameworks", "-property:Configuration=" + configuration, + }; + if (!string.IsNullOrWhiteSpace(framework)) arguments.Add("-property:TargetFramework=" + framework); + var result = await processes.RunAsync(new ProcessRunRequest("dotnet", arguments.ToArray(), Path.GetDirectoryName(projectPath)!, null), cancellationToken).ConfigureAwait(false); + if (result.ExitCode != 0) throw new InvalidOperationException(result.StandardOutput + result.StandardError); + using var json = JsonDocument.Parse(result.StandardOutput); + var properties = json.RootElement.GetProperty("Properties"); + string Value(string name) => properties.GetProperty(name).GetString() ?? ""; + var frameworks = (Value("TargetFrameworks") + ";" + Value("TargetFramework")).Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Distinct().ToArray(); + var assetsPath = Value("ProjectAssetsFile"); + if (assetsPath.Length != 0) assetsPath = Path.GetFullPath(assetsPath, Path.GetDirectoryName(projectPath)!); + return new(assetsPath, bool.TryParse(Value("IsTestProject"), out var isTest) ? isTest : null, frameworks); + } + + internal static async Task TrustedAnalyzerRootsAsync(IProcessRunner processes, CancellationToken cancellationToken) + { + // The dotnet installation selects trusted SDK code, never an MSBuild property controlled by the project. + var result = await processes.RunAsync(new ProcessRunRequest("dotnet", ["--list-sdks"], AppContext.BaseDirectory, null), cancellationToken).ConfigureAwait(false); + if (result.ExitCode != 0) return []; + return result.StandardOutput.Split('\n').Where(line => line.Contains('[') && line.TrimEnd().EndsWith(']')) + .Select(line => Path.GetDirectoryName(line[(line.IndexOf('[') + 1)..line.LastIndexOf(']')])!) + .Where(root => !string.IsNullOrWhiteSpace(root)).Distinct().ToArray(); + } + + internal static bool IsTrustedAnalyzer(string? analyzerPath, string[] roots, IPathService paths) + { + if (analyzerPath is null) return false; + var path = paths.GetCanonicalFullPath(analyzerPath); + return roots.Any(root => + DotNetWorkspaceTargetResolver.IsWithin(paths.GetCanonicalFullPath(Path.Combine(root, "packs")), path) + && path.Replace('\\', '/').Contains("/analyzers/", StringComparison.Ordinal) + || DotNetWorkspaceTargetResolver.IsWithin(paths.GetCanonicalFullPath(Path.Combine(root, "sdk")), path) + && path.Replace('\\', '/').Contains("/Sdks/Microsoft.NET.Sdk/analyzers/", StringComparison.Ordinal)); + } +} diff --git a/src/SharpClaw.Code.Memory/Services/DotNetSemanticWorkspaceCache.cs b/src/SharpClaw.Code.Memory/Services/DotNetSemanticWorkspaceCache.cs index 754389b..a10eae4 100644 --- a/src/SharpClaw.Code.Memory/Services/DotNetSemanticWorkspaceCache.cs +++ b/src/SharpClaw.Code.Memory/Services/DotNetSemanticWorkspaceCache.cs @@ -3,6 +3,7 @@ using System.Xml; using System.Xml.Linq; using System.Text; +using System.Text.Json; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.MSBuild; using Microsoft.CodeAnalysis.Text; @@ -14,43 +15,70 @@ namespace SharpClaw.Code.Memory.Services; /// /// Owns a bounded semantic cache. One async gate protects loading, queries, refresh, and eviction, -/// so a workspace can never be disposed while in use. Cached source-only refresh does not run generators. +/// so a workspace can never be disposed while in use. Human authorization never holds this gate. +/// Only installed SDK generators are retained; project/package-supplied analyzers are excluded. /// -internal sealed class DotNetSemanticWorkspaceCache(IFileSystem fileSystem, IPathService pathService, IDotNetWorkspaceTargetResolver targetResolver) : IDisposable +internal sealed class DotNetSemanticWorkspaceCache(IFileSystem fileSystem, IPathService pathService, IDotNetWorkspaceTargetResolver targetResolver, IProcessRunner processes) : IDisposable { private const int Capacity = 4; private readonly SemaphoreSlim gate = new(1, 1); private readonly Dictionary entries = new(OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); + private readonly ConcurrentDictionary pendingLoads = new(OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); private bool disposed; internal async Task AcquireAsync(DotNetWorkspaceRequest request, IDotNetWorkspaceEvaluationAuthorization? authorization, CancellationToken cancellationToken) { + var target = await targetResolver.ResolveAsync(request, cancellationToken).ConfigureAwait(false); + var key = string.Join('\0', target.WorkspaceRoot, target.TargetPath, request.Configuration, request.TargetFramework); + var authorized = false; + TaskCompletionSource? ownedLoad = null; await gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { ObjectDisposedException.ThrowIf(disposed, this); - var target = await targetResolver.ResolveAsync(request, cancellationToken).ConfigureAwait(false); if (target.Status != DotNetWorkspaceStatus.Ready) { return new Lease(target, null, gate); } - var key = string.Join('\0', target.WorkspaceRoot, target.TargetPath, request.Configuration, request.TargetFramework); - entries.TryGetValue(key, out var snapshot); - var fingerprint = await FingerprintAsync(target, snapshot, cancellationToken).ConfigureAwait(false); - if (snapshot is not null && (!snapshot.Reusable || !string.Equals(snapshot.Fingerprint, fingerprint, StringComparison.Ordinal))) + Snapshot? snapshot; + string fingerprint; + while (true) { - entries.Remove(key); - snapshot.Workspace.Dispose(); - snapshot = null; + ObjectDisposedException.ThrowIf(disposed, this); + entries.TryGetValue(key, out snapshot); + fingerprint = await FingerprintAsync(target, snapshot, cancellationToken).ConfigureAwait(false); + if (snapshot is not null && (!snapshot.Reusable || !string.Equals(snapshot.Fingerprint, fingerprint, StringComparison.Ordinal))) + { + entries.Remove(key); + snapshot.Workspace.Dispose(); + snapshot = null; + } + if (snapshot is not null || authorized || authorization is null) break; + if (pendingLoads.TryGetValue(key, out var pending)) + { + gate.Release(); + try { await pending.Task.WaitAsync(cancellationToken).ConfigureAwait(false); } + finally { await gate.WaitAsync(CancellationToken.None).ConfigureAwait(false); } + cancellationToken.ThrowIfCancellationRequested(); + continue; + } + ownedLoad = new(TaskCreationOptions.RunContinuationsAsynchronously); + pendingLoads[key] = ownedLoad; + gate.Release(); + try { authorized = await authorization.AuthorizeAsync(request with { WorkspaceRoot = target.WorkspaceRoot, Target = target.TargetPath }, cancellationToken).ConfigureAwait(false); } + finally { await gate.WaitAsync(CancellationToken.None).ConfigureAwait(false); } + cancellationToken.ThrowIfCancellationRequested(); + if (!authorized) + { + return new Lease(target with { Status = DotNetWorkspaceStatus.PermissionDenied, Message = "MSBuild project evaluation requires execution authorization; no valid approved snapshot is cached." }, null, gate); + } + // Recheck after approval: another session may have loaded or disposed this cache. } if (snapshot is null) { - if (authorization is null || !await authorization.AuthorizeAsync(request with { WorkspaceRoot = target.WorkspaceRoot, Target = target.TargetPath }, cancellationToken).ConfigureAwait(false)) - { - return new Lease(target with { Status = DotNetWorkspaceStatus.PermissionDenied, Message = "MSBuild project evaluation requires execution authorization; no valid approved snapshot is cached." }, null, gate); - } + if (!authorized) return new Lease(target with { Status = DotNetWorkspaceStatus.PermissionDenied, Message = "MSBuild project evaluation requires execution authorization; no valid approved snapshot is cached." }, null, gate); if (entries.Count == Capacity) { @@ -63,6 +91,7 @@ internal async Task AcquireAsync(DotNetWorkspaceRequest request, IDotNetW if (!string.IsNullOrWhiteSpace(request.TargetFramework)) properties["TargetFramework"] = request.TargetFramework; var workspace = MSBuildWorkspace.Create(properties); var messages = new ConcurrentQueue(); + var stored = false; using var failedHandler = workspace.RegisterWorkspaceFailedHandler(args => messages.Enqueue(args.Diagnostic.ToString())); try { @@ -70,50 +99,54 @@ internal async Task AcquireAsync(DotNetWorkspaceRequest request, IDotNetW var solution = target.TargetPath!.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase) ? (await workspace.OpenProjectAsync(target.TargetPath, cancellationToken: cancellationToken).ConfigureAwait(false)).Solution : await workspace.OpenSolutionAsync(target.TargetPath, cancellationToken: cancellationToken).ConfigureAwait(false); + var trustedRoots = await DotNetProjectEvaluation.TrustedAnalyzerRootsAsync(processes, cancellationToken).ConfigureAwait(false); + var evaluations = new Dictionary(); + var excludedAnalyzers = false; foreach (var project in solution.Projects.ToArray()) { if (project.FilePath is null || !DotNetWorkspaceTargetResolver.IsWithin(target.WorkspaceRoot, pathService.GetCanonicalFullPath(project.FilePath))) { throw new InvalidOperationException("A referenced project is outside the workspace boundary."); } - using var reader = XmlReader.Create(new StringReader(await fileSystem.ReadAllTextIfExistsAsync(project.FilePath, cancellationToken).ConfigureAwait(false) ?? ""), new XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null }); - var projectXml = XDocument.Load(reader); - if (projectXml.Root?.Attribute("Sdk") is not null && !fileSystem.FileExists(Path.Combine(Path.GetDirectoryName(project.FilePath)!, "obj", "project.assets.json"))) + var evaluation = await DotNetProjectEvaluation.ReadAsync(processes, project.FilePath, request.Configuration, request.TargetFramework, cancellationToken).ConfigureAwait(false); + evaluations.Add(project.Id, evaluation); + if (evaluation.AssetsPath.Length != 0 && !fileSystem.FileExists(evaluation.AssetsPath)) { messages.Enqueue("Restore required: project.assets.json is missing for " + project.FilePath); } - // Compiler queries never execute project-supplied analyzers or source generators. - solution = solution.WithProjectAnalyzerReferences(project.Id, []); + var trusted = project.AnalyzerReferences.Where(reference => DotNetProjectEvaluation.IsTrustedAnalyzer(reference.FullPath, trustedRoots, pathService)).ToArray(); + excludedAnalyzers |= trusted.Length != project.AnalyzerReferences.Count; + solution = solution.WithProjectAnalyzerReferences(project.Id, trusted); } var status = messages.Count == 0 ? DotNetWorkspaceStatus.Ready : ClassifyFailure(string.Join('\n', messages), partial: true); if (!solution.Projects.Any() || status is DotNetWorkspaceStatus.RestoreRequired or DotNetWorkspaceStatus.SdkUnavailable) { - workspace.Dispose(); return new Lease(target with { Status = status == DotNetWorkspaceStatus.Ready ? DotNetWorkspaceStatus.LoadFailed : status, Message = string.Join('\n', messages) }, null, gate); } - snapshot = new Snapshot(workspace, solution, status, messages.ToArray(), fingerprint); + if (excludedAnalyzers) messages.Enqueue("Project/package-supplied analyzers and source generators are excluded; diagnostics may be incomplete and semantic rename is disabled. Installed SDK generators are enabled."); + snapshot = new Snapshot(workspace, solution, status, messages.ToArray(), fingerprint, evaluations, excludedAnalyzers); snapshot.Fingerprint = await FingerprintAsync(target, snapshot, cancellationToken).ConfigureAwait(false); entries.Add(key, snapshot); + stored = true; } catch (OperationCanceledException) { - workspace.Dispose(); throw; } - catch (Exception exception) when (exception is IOException or InvalidOperationException or ArgumentException or XmlException || exception.GetType().Name == "RemoteInvocationException") + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or InvalidOperationException or ArgumentException or XmlException or JsonException || exception.GetType().Name is "RemoteInvocationException" or "ConnectionLostException") { - workspace.Dispose(); return new Lease(target with { Status = ClassifyFailure(exception.Message), Message = exception.Message }, null, gate); } + finally { if (!stored) workspace.Dispose(); } } try { await RefreshSourcesAsync(target.WorkspaceRoot, snapshot, cancellationToken).ConfigureAwait(false); } - catch (Exception exception) when (exception is IOException or InvalidOperationException) + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or InvalidOperationException) { entries.Remove(key); snapshot.Workspace.Dispose(); @@ -127,6 +160,14 @@ internal async Task AcquireAsync(DotNetWorkspaceRequest request, IDotNetW gate.Release(); throw; } + finally + { + if (ownedLoad is not null) + { + pendingLoads.TryRemove(key, out _); + ownedLoad.TrySetResult(); + } + } } private async Task RefreshSourcesAsync(string root, Snapshot snapshot, CancellationToken cancellationToken) @@ -162,16 +203,16 @@ private async Task FingerprintAsync(DotNetWorkspaceTarget target, Snapsh } if (snapshot is not null) { - foreach (var project in snapshot.Solution.Projects) + foreach (var evaluation in snapshot.Evaluations.Values) { - var assets = Path.Combine(Path.GetDirectoryName(project.FilePath!)!, "obj", "project.assets.json"); - if (fileSystem.FileExists(assets)) inputs.Add(assets); + if (evaluation.AssetsPath.Length != 0) inputs.Add(evaluation.AssetsPath); } } if (snapshot is not null) { var externalInputs = snapshot.Solution.Projects.SelectMany(project => project.Documents.Select(document => document.FilePath) - .Concat(project.MetadataReferences.OfType().Select(reference => reference.FilePath))) + .Concat(project.MetadataReferences.OfType().Select(reference => reference.FilePath)) + .Concat(project.AnalyzerReferences.Select(reference => reference.FullPath))) .OfType().Where(path => !DotNetWorkspaceTargetResolver.IsWithin(target.WorkspaceRoot, pathService.GetCanonicalFullPath(path))).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal); foreach (var path in externalInputs) { @@ -226,11 +267,15 @@ private IEnumerable EnumerateInputs(string root) { current = pathService.GetCanonicalFullPath(current); if (!DotNetWorkspaceTargetResolver.IsWithin(root, current) || !visited.Add(current)) continue; - foreach (var directory in fileSystem.EnumerateDirectories(current)) + string[] directories; + string[] files; + try { directories = fileSystem.EnumerateDirectories(current).ToArray(); files = fileSystem.EnumerateFiles(current, "*").ToArray(); } + catch (Exception error) when (error is UnauthorizedAccessException or IOException) { continue; } + foreach (var directory in directories) { if (Path.GetFileName(directory).ToLowerInvariant() is not ("bin" or "obj" or ".git" or ".sharpclaw" or "node_modules")) pending.Push(directory); } - foreach (var file in fileSystem.EnumerateFiles(current, "*")) + foreach (var file in files) { if (Path.GetExtension(file).ToLowerInvariant() is ".cs" or ".csproj" or ".sln" or ".slnx" or ".props" or ".targets" or ".editorconfig" || Path.GetFileName(file).ToLowerInvariant() is "global.json" or "packages.lock.json" or "nuget.config") yield return file; @@ -254,16 +299,19 @@ public void Dispose() disposed = true; foreach (var snapshot in entries.Values) snapshot.Workspace.Dispose(); entries.Clear(); + foreach (var pending in pendingLoads.Values) pending.TrySetResult(); } finally { gate.Release(); } } - internal sealed class Snapshot(MSBuildWorkspace workspace, Solution solution, DotNetWorkspaceStatus status, string[] messages, string fingerprint) + internal sealed class Snapshot(MSBuildWorkspace workspace, Solution solution, DotNetWorkspaceStatus status, string[] messages, string fingerprint, Dictionary evaluations, bool excludedAnalyzers) { internal MSBuildWorkspace Workspace { get; } = workspace; internal Solution Solution { get; set; } = solution; internal DotNetWorkspaceStatus Status { get; } = status; internal string[] Messages { get; } = messages; + internal Dictionary Evaluations { get; } = evaluations; + internal bool ExcludedAnalyzers { get; } = excludedAnalyzers; internal string Fingerprint { get; set; } = fingerprint; internal bool Reusable { get; set; } = true; internal DateTimeOffset LastUsedAtUtc { get; set; } = DateTimeOffset.UtcNow; diff --git a/src/SharpClaw.Code.Memory/Services/DotNetWorkspaceSemanticRename.cs b/src/SharpClaw.Code.Memory/Services/DotNetWorkspaceSemanticRename.cs index 23c5c06..7f8d945 100644 --- a/src/SharpClaw.Code.Memory/Services/DotNetWorkspaceSemanticRename.cs +++ b/src/SharpClaw.Code.Memory/Services/DotNetWorkspaceSemanticRename.cs @@ -17,6 +17,7 @@ public async Task PlanRenameAsync(DotNetWorkspaceRequest reque using var lease = await cache.AcquireAsync(request, authorization, cancellationToken).ConfigureAwait(false); var (resolution, candidates) = await ResolveAsync(lease, arguments.Query(), cancellationToken).ConfigureAwait(false); if (resolution.Symbol is null) return new(resolution, arguments.NewName, [], ["Rename requires one exact source symbol."]); + if (lease.Snapshot!.ExcludedAnalyzers) return new(resolution, arguments.NewName, [], ["Rename cannot be verified while project/package source generators are excluded. Use explicit edits followed by build verification."]); var original = lease.Snapshot!.Solution; var renamed = await Renamer.RenameSymbolAsync(original, candidates[0].Symbol, new SymbolRenameOptions(false, false, false, false), arguments.NewName, cancellationToken).ConfigureAwait(false); var changes = new Dictionary(OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); @@ -44,9 +45,38 @@ public async Task PlanRenameAsync(DotNetWorkspaceRequest reque { var oldCompilation = await original.GetProject(project.Id)!.GetCompilationAsync(cancellationToken).ConfigureAwait(false); var newCompilation = await project.GetCompilationAsync(cancellationToken).ConfigureAwait(false); - var existing = oldCompilation?.GetDiagnostics(cancellationToken).Where(item => item.Severity == DiagnosticSeverity.Error).Select(item => item.Id + ":" + item.GetMessage()).ToHashSet(StringComparer.Ordinal) ?? []; - if (newCompilation?.GetDiagnostics(cancellationToken).Any(item => item.Severity == DiagnosticSeverity.Error && !existing.Contains(item.Id + ":" + item.GetMessage())) == true) return new(resolution, arguments.NewName, [], ["Rename would introduce compiler errors; no changes were applied."]); + if (oldCompilation is null || newCompilation is null) return new(resolution, arguments.NewName, [], ["Rename could not be verified by the compiler; no changes were applied."]); + var existing = new Dictionary<(string Code, string Path, int Start), int>(); + foreach (var diagnostic in oldCompilation.GetDiagnostics(cancellationToken).Where(item => item.Severity == DiagnosticSeverity.Error)) + { + var position = diagnostic.Location.SourceSpan.Start; + if (diagnostic.Location.SourceTree is { } oldTree && newCompilation.SyntaxTrees.FirstOrDefault(tree => tree.FilePath == oldTree.FilePath) is { } newTree) + { + var changesToText = newTree.GetChanges(oldTree); + position = MapPosition(position, changesToText); + } + var key = (diagnostic.Id, diagnostic.Location.SourceTree?.FilePath ?? "", position); + existing[key] = existing.GetValueOrDefault(key) + 1; + } + foreach (var diagnostic in newCompilation.GetDiagnostics(cancellationToken).Where(item => item.Severity == DiagnosticSeverity.Error)) + { + var key = (diagnostic.Id, diagnostic.Location.SourceTree?.FilePath ?? "", diagnostic.Location.SourceSpan.Start); + if (existing.GetValueOrDefault(key) == 0) return new(resolution, arguments.NewName, [], ["Rename would introduce compiler errors; no changes were applied."]); + existing[key]--; + } } return new(resolution, arguments.NewName, changes.Values.OrderBy(change => change.Path, StringComparer.Ordinal).ToArray(), []); } + + private static int MapPosition(int position, IEnumerable changes) + { + var delta = 0; + foreach (var change in changes.OrderBy(change => change.Span.Start)) + { + if (position < change.Span.Start) break; + if (position < change.Span.End) return change.Span.Start + delta; + delta += (change.NewText?.Length ?? 0) - change.Span.Length; + } + return position + delta; + } } diff --git a/src/SharpClaw.Code.Memory/Services/DotNetWorkspaceSemanticService.cs b/src/SharpClaw.Code.Memory/Services/DotNetWorkspaceSemanticService.cs index f5b772e..f968d48 100644 --- a/src/SharpClaw.Code.Memory/Services/DotNetWorkspaceSemanticService.cs +++ b/src/SharpClaw.Code.Memory/Services/DotNetWorkspaceSemanticService.cs @@ -4,6 +4,7 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.FindSymbols; using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Infrastructure.Services; using SharpClaw.Code.Memory.Abstractions; using SharpClaw.Code.Protocol.Models; @@ -18,10 +19,14 @@ public sealed partial class DotNetWorkspaceSemanticService : IDotNetWorkspaceSem /// Creates a semantic service with a bounded, lifecycle-owned cache. public DotNetWorkspaceSemanticService(IFileSystem fileSystem, IPathService pathService, IDotNetWorkspaceTargetResolver targetResolver) + : this(fileSystem, pathService, targetResolver, new ProcessRunner(new SystemClock())) { } + + /// Creates a semantic service with an explicit approved project-evaluation process seam. + public DotNetWorkspaceSemanticService(IFileSystem fileSystem, IPathService pathService, IDotNetWorkspaceTargetResolver targetResolver, IProcessRunner processes) { this.fileSystem = fileSystem; this.pathService = pathService; - cache = new DotNetSemanticWorkspaceCache(fileSystem, pathService, targetResolver); + cache = new DotNetSemanticWorkspaceCache(fileSystem, pathService, targetResolver, processes); } /// @@ -42,9 +47,9 @@ public async Task InspectSolutionAsync(DotNetWorkspaceReq var packages = xml.Descendants().Where(element => element.Name.LocalName == "PackageReference") .Where(element => element.Attribute("Include") is not null) .Select(element => new DotNetPackageReference(element.Attribute("Include")!.Value, element.Attribute("Version")?.Value ?? element.Elements().FirstOrDefault(child => child.Name.LocalName == "Version")?.Value)).ToArray(); - var frameworks = xml.Descendants().Where(element => element.Name.LocalName is "TargetFramework" or "TargetFrameworks") - .SelectMany(element => element.Value.Split(';', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)).Distinct().ToArray(); - var assetsPath = Path.Combine(Path.GetDirectoryName(project.FilePath!)!, "obj", "project.assets.json"); + var evaluated = snapshot.Evaluations[project.Id]; + var frameworks = evaluated.TargetFrameworks; + var assetsPath = evaluated.AssetsPath; if (await fileSystem.ReadAllTextIfExistsAsync(assetsPath, cancellationToken).ConfigureAwait(false) is { } assetsText) { using var assets = JsonDocument.Parse(assetsText); @@ -61,15 +66,13 @@ public async Task InspectSolutionAsync(DotNetWorkspaceReq var references = project.ProjectReferences.Select(reference => snapshot.Solution.GetProject(reference.ProjectId)?.FilePath) .OfType().Select(path => Relative(target.WorkspaceRoot, path)).Distinct().Order(StringComparer.Ordinal).ToArray(); var evaluatedFramework = project.OutputFilePath is null ? request.TargetFramework : Path.GetFileName(Path.GetDirectoryName(project.OutputFilePath)); - var isTestProject = xml.Descendants().Any(element => element.Name.LocalName == "IsTestProject" && string.Equals(element.Value, "true", StringComparison.OrdinalIgnoreCase)) - || packages.Any(package => package.Name is "Microsoft.NET.Test.Sdk" or "xunit" or "NUnit" or "MSTest.TestFramework") - || project.Name.EndsWith(".Tests", StringComparison.OrdinalIgnoreCase); + var isTestProject = evaluated.IsTestProject ?? packages.Any(package => package.Name == "Microsoft.NET.Test.Sdk"); projects.Add(new DotNetProjectSummary(project.Name, projectPath, project.Language, frameworks, evaluatedFramework, isTestProject, project.DocumentIds.Count, references, packages, project.Documents.Where(document => document.FilePath is not null).Select(document => Relative(target.WorkspaceRoot, document.FilePath!)).Distinct().ToArray(), project.OutputFilePath is null ? null : Relative(target.WorkspaceRoot, project.OutputFilePath))); } return new DotNetSolutionSummary(snapshot.Status, target.WorkspaceRoot, Relative(target.WorkspaceRoot, target.TargetPath!), projects.ToArray(), projects.SelectMany(project => project.ProjectReferences.Select(reference => new DotNetProjectReference(project.Path, reference))).ToArray(), - [.. snapshot.Messages, "Project-supplied analyzers and source generators are excluded from compiler queries."]); + snapshot.Messages); } /// diff --git a/src/SharpClaw.Code.Protocol/Models/SharpClawWorkflowMetadataKeys.cs b/src/SharpClaw.Code.Protocol/Models/SharpClawWorkflowMetadataKeys.cs index b2ec947..804f35d 100644 --- a/src/SharpClaw.Code.Protocol/Models/SharpClawWorkflowMetadataKeys.cs +++ b/src/SharpClaw.Code.Protocol/Models/SharpClawWorkflowMetadataKeys.cs @@ -117,4 +117,6 @@ public static string GetManagedSessionTodoMapKey(string ownerAgentId) public const string VerificationMaxRepairIterations = "sharpclaw.verification.maxRepairIterations"; /// Explicitly enables restore for automatic verification. public const string VerificationAllowRestore = "sharpclaw.verification.allowRestore"; + /// Overrides the automatic verification per-step and lock-wait timeout, in seconds. + public const string VerificationTimeoutSeconds = "sharpclaw.verification.timeoutSeconds"; } diff --git a/src/SharpClaw.Code.Protocol/Models/VerificationPolicyModels.cs b/src/SharpClaw.Code.Protocol/Models/VerificationPolicyModels.cs index 0fffc99..25ac3be 100644 --- a/src/SharpClaw.Code.Protocol/Models/VerificationPolicyModels.cs +++ b/src/SharpClaw.Code.Protocol/Models/VerificationPolicyModels.cs @@ -1,8 +1,8 @@ namespace SharpClaw.Code.Protocol.Models; /// Nullable configuration fields support user/workspace precedence; omitted verification remains disabled. -public sealed record VerificationOptions(bool? Enabled = null, VerificationScope? Scope = null, bool? RunAfterMutatingTurn = null, int? MaxRepairIterations = null, bool? AllowRestore = null); +public sealed record VerificationOptions(bool? Enabled = null, VerificationScope? Scope = null, bool? RunAfterMutatingTurn = null, int? MaxRepairIterations = null, bool? AllowRestore = null, int? TimeoutSeconds = null); /// Validated verification behavior for one logical turn. -public sealed record VerificationPolicy(bool Enabled = false, VerificationScope Scope = VerificationScope.Affected, bool RunAfterMutatingTurn = true, int MaxRepairIterations = 2, bool AllowRestore = false); +public sealed record VerificationPolicy(bool Enabled = false, VerificationScope Scope = VerificationScope.Affected, bool RunAfterMutatingTurn = true, int MaxRepairIterations = 2, bool AllowRestore = false, int TimeoutSeconds = 600); /// A correlated provider request and its observed events from one agent pass. public sealed record ProviderInvocationRecord(ProviderRequest Request, ProviderEvent[] Events); diff --git a/src/SharpClaw.Code.Runtime/Configuration/SharpClawConfigService.cs b/src/SharpClaw.Code.Runtime/Configuration/SharpClawConfigService.cs index 70118c6..c8c3493 100644 --- a/src/SharpClaw.Code.Runtime/Configuration/SharpClawConfigService.cs +++ b/src/SharpClaw.Code.Runtime/Configuration/SharpClawConfigService.cs @@ -131,12 +131,12 @@ private static SharpClawConfigDocument CreateDefaultDocument() new ExternalAgentsConfig(), new SkillPacksConfig(), new WorkItemsConfig(), - new VerificationOptions(false, VerificationScope.Affected, true, 2, false)); + new VerificationOptions(false, VerificationScope.Affected, true, 2, false, 600)); private static VerificationOptions MergeVerification(VerificationOptions? user, VerificationOptions? workspace) => new(workspace?.Enabled ?? user?.Enabled ?? false, workspace?.Scope ?? user?.Scope ?? VerificationScope.Affected, workspace?.RunAfterMutatingTurn ?? user?.RunAfterMutatingTurn ?? true, workspace?.MaxRepairIterations ?? user?.MaxRepairIterations ?? 2, - workspace?.AllowRestore ?? user?.AllowRestore ?? false); + workspace?.AllowRestore ?? user?.AllowRestore ?? false, workspace?.TimeoutSeconds ?? user?.TimeoutSeconds ?? 600); private static ExternalAgentsConfig MergeExternalAgents(ExternalAgentsConfig? user, ExternalAgentsConfig? workspace) { diff --git a/src/SharpClaw.Code.Runtime/Verification/AffectedProjectResolver.cs b/src/SharpClaw.Code.Runtime/Verification/AffectedProjectResolver.cs index a892a91..73623aa 100644 --- a/src/SharpClaw.Code.Runtime/Verification/AffectedProjectResolver.cs +++ b/src/SharpClaw.Code.Runtime/Verification/AffectedProjectResolver.cs @@ -7,7 +7,7 @@ public sealed class AffectedProjectResolver : IAffectedProjectResolver public AffectedProjectSelection Resolve(DotNetSolutionSummary solution, IReadOnlyList? changedPaths) { var comparer = OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; - AffectedProjectSelection All() => new(solution.Projects.Select(project => project.Path).Order(comparer).ToArray(), true); + AffectedProjectSelection All() => new(solution.Projects.Select(project => project.Path).Distinct(comparer).Order(comparer).ToArray(), true); if (changedPaths is null || changedPaths.Count == 0 || solution.Status != DotNetWorkspaceStatus.Ready) return All(); var selected = new HashSet(comparer); foreach (var changed in changedPaths) diff --git a/src/SharpClaw.Code.Runtime/Verification/IVerificationDiagnosticsCache.cs b/src/SharpClaw.Code.Runtime/Verification/IVerificationDiagnosticsCache.cs index 8487953..aafc38c 100644 --- a/src/SharpClaw.Code.Runtime/Verification/IVerificationDiagnosticsCache.cs +++ b/src/SharpClaw.Code.Runtime/Verification/IVerificationDiagnosticsCache.cs @@ -3,8 +3,8 @@ namespace SharpClaw.Code.Runtime.Verification; /// Supplies only diagnostics from previously authorized verification; reading never executes project code. public interface IVerificationDiagnosticsCache { - /// Stores the latest report with bounded workspace retention. + /// Stores the latest report containing a build, with bounded workspace retention. void Store(VerificationRunReport report); - /// Gets a recent authorized report, if one exists. + /// Gets a recent authorized build report, if one exists. VerificationRunReport? Get(string workspaceRoot); } diff --git a/src/SharpClaw.Code.Runtime/Verification/TrxTestResultParser.cs b/src/SharpClaw.Code.Runtime/Verification/TrxTestResultParser.cs index 64c14f7..6e55a8c 100644 --- a/src/SharpClaw.Code.Runtime/Verification/TrxTestResultParser.cs +++ b/src/SharpClaw.Code.Runtime/Verification/TrxTestResultParser.cs @@ -23,8 +23,8 @@ public VerificationTestRunReport Parse(string xml, string projectPath, string? t if (skipped < 0 || results.Length < total) throw new XmlException("TRX results are incomplete."); var cases = results.OrderBy(element => string.Equals(element.Attribute("outcome")?.Value, "Failed", StringComparison.OrdinalIgnoreCase) ? 0 : 1).Take(500).Select(element => new VerificationTestCaseResult(projectPath, targetFramework, element.Attribute("testName")?.Value ?? "(unnamed)", element.Attribute("outcome")?.Value ?? "Unknown", Text(element, "Message"), Text(element, "StackTrace"))).ToArray(); var nonPassing = results.Any(element => element.Attribute("outcome")?.Value is not ("Passed" or "NotExecuted" or "Inconclusive")); - var status = failed > 0 || nonPassing || total == 0 ? VerificationStatus.Failed : VerificationStatus.Passed; - return new(status, status == VerificationStatus.Passed ? VerificationFailureReason.None : VerificationFailureReason.TestFailed, total, passed, failed, skipped, cases, [], []); + var status = failed > 0 || nonPassing ? VerificationStatus.Failed : total == 0 ? VerificationStatus.Skipped : VerificationStatus.Passed; + return new(status, status == VerificationStatus.Failed ? VerificationFailureReason.TestFailed : total == 0 ? VerificationFailureReason.NoTestProjects : VerificationFailureReason.None, total, passed, failed, skipped, cases, [], []); } catch (Exception error) when (error is XmlException or InvalidOperationException) { diff --git a/src/SharpClaw.Code.Runtime/Verification/VerificationDiagnosticsCache.cs b/src/SharpClaw.Code.Runtime/Verification/VerificationDiagnosticsCache.cs index c6594ba..869a6f3 100644 --- a/src/SharpClaw.Code.Runtime/Verification/VerificationDiagnosticsCache.cs +++ b/src/SharpClaw.Code.Runtime/Verification/VerificationDiagnosticsCache.cs @@ -2,14 +2,15 @@ using SharpClaw.Code.Infrastructure.Abstractions; using SharpClaw.Code.Protocol.Models; namespace SharpClaw.Code.Runtime.Verification; -/// Retains up to fifty recent authorized reports, independently of prompt snapshot caching. +/// Retains up to fifty recent authorized build reports, independently of prompt snapshot caching. public sealed class VerificationDiagnosticsCache(IPathService paths, ISystemClock clock) : IVerificationDiagnosticsCache { private readonly ConcurrentDictionary reports = new(OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); /// public void Store(VerificationRunReport report) { - reports[paths.GetCanonicalFullPath(report.WorkspaceRoot)] = report; + if (report.Build is null) return; + reports.AddOrUpdate(paths.GetCanonicalFullPath(report.WorkspaceRoot), report, (_, previous) => report.CompletedAtUtc >= previous.CompletedAtUtc ? report : previous); foreach (var key in reports.OrderBy(pair => pair.Value.CompletedAtUtc).Take(Math.Max(0, reports.Count - 50)).Select(pair => pair.Key)) reports.TryRemove(key, out _); } /// diff --git a/src/SharpClaw.Code.Runtime/Verification/VerificationLoopCoordinator.cs b/src/SharpClaw.Code.Runtime/Verification/VerificationLoopCoordinator.cs index b4cb9ec..cbf7580 100644 --- a/src/SharpClaw.Code.Runtime/Verification/VerificationLoopCoordinator.cs +++ b/src/SharpClaw.Code.Runtime/Verification/VerificationLoopCoordinator.cs @@ -65,7 +65,7 @@ public async Task RunAsync(ISharpClawAgent agent, AgentR async Task VerifyAsync(int attempt) { if (!prior.OfType().Any(item => item.Attempt == attempt)) await PublishAsync(new VerificationStartedEvent(Id(), context.SessionId, context.TurnId, DateTimeOffset.UtcNow, attempt, policy.Scope, limit), cancellationToken).ConfigureAwait(false); - var arguments = new VerifyWorkspaceArguments(policy.Scope, Restore: policy.AllowRestore, ChangedPaths: mutations.ToSnapshot().Select(operation => operation.RelativePath).Distinct(StringComparer.Ordinal).ToArray()); + var arguments = new VerifyWorkspaceArguments(policy.Scope, Restore: policy.AllowRestore, ChangedPaths: mutations.ToSnapshot().Select(operation => operation.RelativePath).Distinct(StringComparer.Ordinal).ToArray(), TimeoutSeconds: policy.TimeoutSeconds); var toolContext = new ToolExecutionContext(context.SessionId, context.TurnId, context.WorkingDirectory, context.WorkingDirectory, EffectiveMode(context), OutputFormat.Json, null, context.Model, agent.AgentId, context.Metadata, AllowedTools: Names(SharpClawWorkflowMetadataKeys.AgentAllowedToolsJson), IsInteractive: context.IsInteractive, SourceKind: PermissionRequestSourceKind.Runtime, TrustedPluginNames: Names(SharpClawWorkflowMetadataKeys.TrustedPluginNamesJson), TrustedMcpServerNames: Names(SharpClawWorkflowMetadataKeys.TrustedMcpServerNamesJson), PrimaryMode: context.PrimaryMode, MutationRecorder: mutations, ApprovalSettings: context.ApprovalSettings); diff --git a/src/SharpClaw.Code.Runtime/Verification/VerificationPolicyResolver.cs b/src/SharpClaw.Code.Runtime/Verification/VerificationPolicyResolver.cs index b2e428f..c67e90f 100644 --- a/src/SharpClaw.Code.Runtime/Verification/VerificationPolicyResolver.cs +++ b/src/SharpClaw.Code.Runtime/Verification/VerificationPolicyResolver.cs @@ -12,10 +12,12 @@ public async Task ResolveAsync(string workspaceRoot, IReadOn var restore = Boolean(SharpClawWorkflowMetadataKeys.VerificationAllowRestore, options.AllowRestore ?? false); var scope = options.Scope ?? VerificationScope.Affected; var repairs = options.MaxRepairIterations ?? 2; + var timeout = options.TimeoutSeconds ?? 600; if (metadata?.TryGetValue(SharpClawWorkflowMetadataKeys.VerificationScope, out var scopeText) == true && !Enum.TryParse(scopeText, true, out scope)) throw new ArgumentException("Invalid verification scope."); if (metadata?.TryGetValue(SharpClawWorkflowMetadataKeys.VerificationMaxRepairIterations, out var repairText) == true && !int.TryParse(repairText, out repairs)) throw new ArgumentException("Invalid verification repair budget."); - if (!Enum.IsDefined(scope) || repairs is < 0 or > 5) throw new ArgumentException("Verification repair budget must be 0–5 and scope must be supported."); - return new(enabled, scope, options.RunAfterMutatingTurn ?? true, repairs, restore); + if (metadata?.TryGetValue(SharpClawWorkflowMetadataKeys.VerificationTimeoutSeconds, out var timeoutText) == true && !int.TryParse(timeoutText, out timeout)) throw new ArgumentException("Invalid verification timeout."); + if (!Enum.IsDefined(scope) || repairs is < 0 or > 5 || timeout is < 1 or > 3600) throw new ArgumentException("Verification repair budget must be 0–5, timeout 1–3600 seconds, and scope must be supported."); + return new(enabled, scope, options.RunAfterMutatingTurn ?? true, repairs, restore, timeout); bool Boolean(string key, bool fallback) => metadata?.TryGetValue(key, out var value) == true ? bool.TryParse(value, out var parsed) ? parsed : throw new ArgumentException("Invalid boolean metadata: " + key) : fallback; } } diff --git a/src/SharpClaw.Code.Runtime/Verification/VerificationService.cs b/src/SharpClaw.Code.Runtime/Verification/VerificationService.cs index a9913cb..8b1500b 100644 --- a/src/SharpClaw.Code.Runtime/Verification/VerificationService.cs +++ b/src/SharpClaw.Code.Runtime/Verification/VerificationService.cs @@ -35,7 +35,22 @@ VerificationRunReport Finish(VerificationStatus status, VerificationFailureReaso if (target.Status != DotNetWorkspaceStatus.Ready) return Finish(target.Status == DotNetWorkspaceStatus.NoTarget ? VerificationStatus.Skipped : VerificationStatus.Failed, Map(target.Status), target.Message ?? target.Status.ToString()); var context = new ToolExecutionContext("", "", root, root, PermissionMode.DangerFullAccess, OutputFormat.Json, null); var resolver = new WorkspacePathResolver(paths); - await using var workspaceLock = await files.AcquireExclusiveFileLockAsync(resolver.ResolvePath(context, ".sharpclaw/locks/verification.lock"), cancellationToken).ConfigureAwait(false); + IAsyncDisposable workspaceLock; + using (var lockTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)) + { + lockTimeout.CancelAfter(TimeSpan.FromSeconds(request.TimeoutSeconds)); + try + { + while (true) + { + try { workspaceLock = await files.AcquireExclusiveFileLockAsync(resolver.ResolvePath(context, ".sharpclaw/locks/verification.lock"), lockTimeout.Token).ConfigureAwait(false); break; } + catch (IOException) { await Task.Delay(50, lockTimeout.Token).ConfigureAwait(false); } + } + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { return Finish(VerificationStatus.Failed, VerificationFailureReason.Timeout, "Timed out waiting for another workspace verification to finish."); } + } + await using var lockHandle = workspaceLock; if (request.Restore) { restore = await RunAsync("restore", targetName!, ["restore", targetName!, "--nologo", "--disable-build-servers"], root, request.TimeoutSeconds, cancellationToken).ConfigureAwait(false); @@ -64,7 +79,8 @@ VerificationRunReport Finish(VerificationStatus status, VerificationFailureReaso } catch (Exception error) when (error is IOException or InvalidOperationException or Win32Exception) { changed = null; } } - var selection = request.Scope == VerificationScope.Affected ? affected.Resolve(workspace, changed) : new AffectedProjectSelection(workspace.Projects.Select(project => project.Path).ToArray(), true); + var comparer = OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + var selection = request.Scope == VerificationScope.Affected ? affected.Resolve(workspace, changed) : new AffectedProjectSelection(workspace.Projects.Select(project => project.Path).Distinct(comparer).ToArray(), true); selected = selection.Projects; if (request.Scope != VerificationScope.Tests) { @@ -87,7 +103,7 @@ VerificationRunReport Finish(VerificationStatus status, VerificationFailureReaso build = new(VerificationStatus.Passed, steps.ToArray(), allDiagnostics.Distinct().ToArray()); } if (request.Scope == VerificationScope.Build) return Finish(VerificationStatus.Passed, VerificationFailureReason.None, "Build passed."); - var testProjects = workspace.Projects.Where(project => project.IsTestProject && selected.Contains(project.Path, OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal)).ToArray(); + var testProjects = workspace.Projects.Where(project => project.IsTestProject && selected.Contains(project.Path, comparer)).DistinctBy(project => project.Path, comparer).ToArray(); if (testProjects.Length == 0) { tests = new(VerificationStatus.Skipped, VerificationFailureReason.NoTestProjects, 0, 0, 0, 0, [], [], ["No test projects were selected."]); @@ -100,7 +116,8 @@ VerificationRunReport Finish(VerificationStatus status, VerificationFailureReaso { foreach (var project in testProjects) { - if (request.Scope == VerificationScope.Tests && (project.OutputPath is null || !files.FileExists(resolver.ResolvePath(context, project.OutputPath)))) + if (request.Scope == VerificationScope.Tests && workspace.Projects.Where(item => comparer.Equals(item.Path, project.Path)) + .Any(item => item.OutputPath is null || !files.FileExists(resolver.ResolvePath(context, item.OutputPath)))) { tests = Merge(reports, testSteps, VerificationStatus.Failed, VerificationFailureReason.TestOutputUnavailable, ["Build the selected test project/configuration before using tests scope: " + project.Path]); return Finish(VerificationStatus.Failed, VerificationFailureReason.TestOutputUnavailable, "Test output is unavailable; tests scope does not build."); @@ -117,7 +134,7 @@ VerificationRunReport Finish(VerificationStatus status, VerificationFailureReaso var report = await files.ReadAllTextIfExistsAsync(reportPath, cancellationToken).ConfigureAwait(false); reports.Add(trx.Parse(report ?? "", project.Path, request.TargetFramework ?? (project.TargetFrameworks.Length == 1 ? project.EvaluatedTargetFramework : null))); } - if (reportPaths.Length == 0 || step.Status != VerificationStatus.Passed || reports.Any(report => report.Status != VerificationStatus.Passed)) + if (reportPaths.Length == 0 || step.Status != VerificationStatus.Passed || reports.Any(report => report.Status == VerificationStatus.Failed)) { var reason = step.Status != VerificationStatus.Passed ? step.Reason : VerificationFailureReason.TestResultsMissing; if (reports.Any(report => report.Failed > 0)) reason = VerificationFailureReason.TestFailed; @@ -125,6 +142,11 @@ VerificationRunReport Finish(VerificationStatus status, VerificationFailureReaso return Finish(VerificationStatus.Failed, reason, "Test verification failed or was incomplete."); } } + if (reports.All(report => report.Total == 0)) + { + tests = Merge(reports, testSteps, VerificationStatus.Skipped, VerificationFailureReason.NoTestProjects, ["The selected test projects contain no tests."]); + return Finish(build is null ? VerificationStatus.Skipped : VerificationStatus.Passed, VerificationFailureReason.NoTestProjects, "Test execution discovered no tests."); + } tests = Merge(reports, testSteps, VerificationStatus.Passed, VerificationFailureReason.None, []); return Finish(VerificationStatus.Passed, VerificationFailureReason.None, "Build/test verification passed."); } @@ -143,8 +165,9 @@ private async Task RunAsync(string kind, string target, try { var result = await processes.RunAsync(new ProcessRunRequest("dotnet", arguments.ToArray(), root, new Dictionary { ["DOTNET_NOLOGO"] = "1", ["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1" }), timeout.Token).ConfigureAwait(false); - var reason = result.ExitCode == 0 ? VerificationFailureReason.None : Classify(result.StandardOutput + result.StandardError, kind); - return new(kind, target, result.ExitCode == 0 ? VerificationStatus.Passed : VerificationStatus.Failed, reason, result.ExitCode, started, clock.UtcNow, (long)(clock.UtcNow - started).TotalMilliseconds, DotNetDiagnosticParser.Bound(result.StandardOutput, 16000), DotNetDiagnosticParser.Bound(result.StandardError, 16000), diagnostics.Parse(result.StandardOutput, result.StandardError, root)); + var parsed = diagnostics.Parse(result.StandardOutput, result.StandardError, root); + var reason = result.ExitCode == 0 ? VerificationFailureReason.None : Classify(parsed, result.StandardOutput + "\n" + result.StandardError, kind); + return new(kind, target, result.ExitCode == 0 ? VerificationStatus.Passed : VerificationStatus.Failed, reason, result.ExitCode, started, clock.UtcNow, (long)(clock.UtcNow - started).TotalMilliseconds, DotNetDiagnosticParser.Bound(result.StandardOutput, 16000), DotNetDiagnosticParser.Bound(result.StandardError, 16000), parsed); } catch (OperationCanceledException) when (!caller.IsCancellationRequested) { @@ -156,10 +179,12 @@ private async Task RunAsync(string kind, string target, } VerificationStepReport Failure(VerificationFailureReason reason, string error) => new(kind, target, VerificationStatus.Failed, reason, null, started, clock.UtcNow, (long)(clock.UtcNow - started).TotalMilliseconds, "", error); } - private static VerificationFailureReason Classify(string text, string kind) + private static VerificationFailureReason Classify(VerificationDiagnostic[] diagnostics, string text, string kind) { - if (text.Contains("NETSDK1004", StringComparison.Ordinal) || text.Contains("project.assets.json", StringComparison.OrdinalIgnoreCase)) return VerificationFailureReason.RestoreRequired; - if (text.Contains("SDK", StringComparison.OrdinalIgnoreCase) && text.Contains("not found", StringComparison.OrdinalIgnoreCase)) return VerificationFailureReason.SdkUnavailable; + var errors = diagnostics.Where(item => item.Severity == "error").ToArray(); + if (errors.Any(item => item.Code == "NETSDK1004")) return VerificationFailureReason.RestoreRequired; + if (errors.Any(item => item.Code is "NETSDK1045" or "NETSDK1141" or "MSB4236" or "MSB4276") + || text.Split('\n').Any(line => line.Trim() is "A compatible .NET SDK was not found." or "No .NET SDKs were found.")) return VerificationFailureReason.SdkUnavailable; return kind == "test" ? VerificationFailureReason.TestFailed : kind == "build" ? VerificationFailureReason.BuildFailed : VerificationFailureReason.ProcessFailed; } private static VerificationFailureReason Map(DotNetWorkspaceStatus status) => status switch diff --git a/tests/SharpClaw.Code.IntegrationTests/Fixtures/DotNetFixtureWorkspace.cs b/tests/SharpClaw.Code.IntegrationTests/Fixtures/DotNetFixtureWorkspace.cs index e90cf31..414a17e 100644 --- a/tests/SharpClaw.Code.IntegrationTests/Fixtures/DotNetFixtureWorkspace.cs +++ b/tests/SharpClaw.Code.IntegrationTests/Fixtures/DotNetFixtureWorkspace.cs @@ -37,16 +37,21 @@ internal static async Task CreateAsync(bool includeTests var solution = Path.Combine(fixture.Root, "Fixture.slnx"); await File.WriteAllTextAsync(solution, (await File.ReadAllTextAsync(solution)).Replace("", " \n", StringComparison.Ordinal)); } - var feed = Path.Combine(fixture.Root, "empty-feed"); - Directory.CreateDirectory(feed); - using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(120)); - var result = await new ProcessRunner(new SystemClock()).RunAsync(new ProcessRunRequest("dotnet", ["restore", "Fixture.slnx", "--source", feed, "-p:NuGetAudit=false", "--disable-build-servers", "-m:1", "-nodeReuse:false"], fixture.Root, null), timeout.Token); - if (result.ExitCode != 0) throw new InvalidOperationException(result.StandardOutput + result.StandardError); + await fixture.RestoreAsync(); return fixture; } catch { fixture.Dispose(); throw; } } + internal async Task RestoreAsync() + { + var feed = Path.Combine(Root, "empty-feed"); + Directory.CreateDirectory(feed); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(120)); + var result = await new ProcessRunner(new SystemClock()).RunAsync(new ProcessRunRequest("dotnet", ["restore", "Fixture.slnx", "--source", feed, "-p:NuGetAudit=false", "--disable-build-servers", "-m:1", "-nodeReuse:false"], Root, null), timeout.Token); + if (result.ExitCode != 0) throw new InvalidOperationException(result.StandardOutput + result.StandardError); + } + public void Dispose() { Service.Dispose(); diff --git a/tests/SharpClaw.Code.IntegrationTests/Fixtures/FaultingFileSystem.cs b/tests/SharpClaw.Code.IntegrationTests/Fixtures/FaultingFileSystem.cs index 7c0667d..5004b14 100644 --- a/tests/SharpClaw.Code.IntegrationTests/Fixtures/FaultingFileSystem.cs +++ b/tests/SharpClaw.Code.IntegrationTests/Fixtures/FaultingFileSystem.cs @@ -8,13 +8,14 @@ internal sealed class FaultingFileSystem : IFileSystem private readonly LocalFileSystem inner = new(); internal Action? BeforeWrite { get; set; } internal Action? BeforeRead { get; set; } + internal Action? BeforeEnumerate { get; set; } private int writes; public Task AcquireExclusiveFileLockAsync(string path, CancellationToken cancellationToken = default) => inner.AcquireExclusiveFileLockAsync(path, cancellationToken); public void CreateDirectory(string path) => inner.CreateDirectory(path); public bool FileExists(string path) => inner.FileExists(path); public bool DirectoryExists(string path) => inner.DirectoryExists(path); - public IEnumerable EnumerateDirectories(string path) => inner.EnumerateDirectories(path); - public IEnumerable EnumerateFiles(string path, string pattern) => inner.EnumerateFiles(path, pattern); + public IEnumerable EnumerateDirectories(string path) { BeforeEnumerate?.Invoke(path); return inner.EnumerateDirectories(path); } + public IEnumerable EnumerateFiles(string path, string pattern) { BeforeEnumerate?.Invoke(path); return inner.EnumerateFiles(path, pattern); } public Task ReadAllTextIfExistsAsync(string path, CancellationToken token) { BeforeRead?.Invoke(path); return inner.ReadAllTextIfExistsAsync(path, token); } public Task ReadAllLinesIfExistsAsync(string path, CancellationToken token) => inner.ReadAllLinesIfExistsAsync(path, token); public Task WriteAllTextAsync(string path, string content, CancellationToken token) diff --git a/tests/SharpClaw.Code.IntegrationTests/Mcp/InboundMcpHttpTests.cs b/tests/SharpClaw.Code.IntegrationTests/Mcp/InboundMcpHttpTests.cs index 9a0ab01..c843164 100644 --- a/tests/SharpClaw.Code.IntegrationTests/Mcp/InboundMcpHttpTests.cs +++ b/tests/SharpClaw.Code.IntegrationTests/Mcp/InboundMcpHttpTests.cs @@ -1,5 +1,6 @@ using System.Net; using System.Net.Sockets; +using System.Diagnostics; using System.Text; using FluentAssertions; using Microsoft.Extensions.DependencyInjection; @@ -70,17 +71,90 @@ public async Task Elevated_http_requires_token_and_keeps_client_durable_sessions (await first.ListToolsAsync()).Should().HaveCount(9); } + /// Only explicit listeners bind, even with hostile workspace and environment endpoint configuration. + [Fact] + public async Task Workspace_and_environment_cannot_override_validated_listener() + { + using var fixture = await DotNetFixtureWorkspace.CreateAsync(); + var expectedPort = FreePort(); + var hostilePort = FreePort(); + await File.WriteAllTextAsync(Path.Combine(fixture.Root, "appsettings.json"), """{"Kestrel":{"Endpoints":{"Hostile":{"Url":"http://0.0.0.0:PORT"}}}}""".Replace("PORT", hostilePort.ToString(), StringComparison.Ordinal)); + var start = new ProcessStartInfo("dotnet") { WorkingDirectory = fixture.Root, UseShellExecute = false, RedirectStandardError = true, RedirectStandardOutput = true }; + foreach (var argument in new[] { "exec", typeof(CliHostBuilder).Assembly.Location, "mcp", "serve", "--transport", "http", "--port", expectedPort.ToString(), "--cwd", fixture.Root }) start.ArgumentList.Add(argument); + start.Environment["Kestrel__Endpoints__Hostile__Url"] = $"http://0.0.0.0:{hostilePort}"; + start.Environment["ASPNETCORE_URLS"] = $"http://0.0.0.0:{hostilePort}"; + using var process = Process.Start(start)!; + var output = process.StandardOutput.ReadToEndAsync(); + var errors = process.StandardError.ReadToEndAsync(); + try + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(20)); + using var http = new HttpClient(); + while (true) + { + process.HasExited.Should().BeFalse("server must bind the explicit loopback listener"); + try { using var response = await http.GetAsync($"http://127.0.0.1:{expectedPort}/mcp", timeout.Token); break; } + catch (HttpRequestException) { await Task.Delay(50, timeout.Token); } + } + using var socket = new TcpClient(); + Func connect = () => socket.ConnectAsync(IPAddress.Loopback, hostilePort); + await connect.Should().ThrowAsync(); + } + finally + { + if (!process.HasExited) process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(); + await Task.WhenAll(output, errors); + } + } + + /// Proxy hostnames require authentication and only their configured HTTPS origin is accepted. + [Fact] + public async Task Local_tls_proxy_accepts_explicit_dns_authority_and_default_https_port() + { + using var fixture = await DotNetFixtureWorkspace.CreateAsync(); + const string token = "test-only-proxy-token-123456"; + await using var server = await HttpHost.StartAsync(new(fixture.Root, Transport: SharpClawMcpTransport.Http, Port: FreePort(), BearerToken: token, AllowedHosts: ["mcp.example.com"])); + using var http = new HttpClient(); + using var request = new HttpRequestMessage(HttpMethod.Get, server.Endpoint); + request.Headers.Host = "mcp.example.com"; + request.Headers.Add("Origin", "https://mcp.example.com"); + (await http.SendAsync(request)).StatusCode.Should().Be(HttpStatusCode.Unauthorized); + using var authenticated = new HttpRequestMessage(HttpMethod.Get, server.Endpoint); + authenticated.Headers.Host = "mcp.example.com:443"; + authenticated.Headers.Add("Origin", "https://mcp.example.com"); + authenticated.Headers.Authorization = new("Bearer", token); + var response = await http.SendAsync(authenticated); + response.StatusCode.Should().NotBe(HttpStatusCode.BadRequest).And.NotBe(HttpStatusCode.Forbidden).And.NotBe(HttpStatusCode.Unauthorized); + using var plaintext = new HttpRequestMessage(HttpMethod.Get, server.Endpoint); + plaintext.Headers.Host = "mcp.example.com"; + plaintext.Headers.Add("Origin", "http://mcp.example.com"); + plaintext.Headers.Authorization = new("Bearer", token); + (await http.SendAsync(plaintext)).StatusCode.Should().Be(HttpStatusCode.Forbidden); + } + /// Rejects unsafe or unauthenticated HTTP exposure. [Theory] [InlineData("0.0.0.0", false, null)] [InlineData("::", true, "test-only-token-123456")] [InlineData("192.0.2.1", false, "test-only-token-123456")] [InlineData("192.0.2.1", true, null)] + [InlineData("192.0.2.1", true, "test-only-token-123456")] [InlineData("localhost", false, "short")] public void Invalid_exposure_is_rejected_before_listening(string address, bool remote, string? token) => FluentActions.Invoking(() => SharpClawMcpServer.ValidateHttpOptions(new(".", Host: address, AllowRemote: remote, BearerToken: token))) .Should().Throw(); + /// Proxied names cannot enable unauthenticated or wildcard access. + [Theory] + [InlineData("mcp.example.com", null)] + [InlineData("*.example.com", "test-only-token-123456")] + [InlineData("mcp.example.com/path", "test-only-token-123456")] + [InlineData("mcp.example.com@evil.example", "test-only-token-123456")] + public void Invalid_proxy_configuration_is_rejected(string host, string? token) + => FluentActions.Invoking(() => SharpClawMcpServer.ValidateHttpOptions(new(".", BearerToken: token, AllowedHosts: [host]))) + .Should().Throw(); + private static Task ClientAsync(Uri endpoint, string? token = null) => McpClient.CreateAsync(new HttpClientTransport(new HttpClientTransportOptions { diff --git a/tests/SharpClaw.Code.IntegrationTests/Mcp/SdkMcpProcessSupervisorIntegrationTests.cs b/tests/SharpClaw.Code.IntegrationTests/Mcp/SdkMcpProcessSupervisorIntegrationTests.cs index cf43e62..9cb9274 100644 --- a/tests/SharpClaw.Code.IntegrationTests/Mcp/SdkMcpProcessSupervisorIntegrationTests.cs +++ b/tests/SharpClaw.Code.IntegrationTests/Mcp/SdkMcpProcessSupervisorIntegrationTests.cs @@ -33,7 +33,7 @@ public async Task StartAsync_stdio_fixture_establishes_session_lists_tools_and_s var result = await supervisor.StartAsync(def, cwd, CancellationToken.None); - result.Started.Should().BeTrue(); + result.Started.Should().BeTrue($"fixture startup failed: {result.FailureReason}"); result.HandshakeSucceeded.Should().BeTrue($"handshake failed: {result.FailureReason}"); result.ToolCount.Should().BeGreaterThan(0); result.SessionHandle.Should().BeGreaterThan(0); diff --git a/tests/SharpClaw.Code.IntegrationTests/Memory/SemanticReviewRegressionTests.cs b/tests/SharpClaw.Code.IntegrationTests/Memory/SemanticReviewRegressionTests.cs new file mode 100644 index 0000000..9dd0d2c --- /dev/null +++ b/tests/SharpClaw.Code.IntegrationTests/Memory/SemanticReviewRegressionTests.cs @@ -0,0 +1,136 @@ +using FluentAssertions; +using System.Xml.Linq; +using SharpClaw.Code.Infrastructure.Services; +using SharpClaw.Code.IntegrationTests.Fixtures; +using SharpClaw.Code.Memory.Abstractions; +using SharpClaw.Code.Memory.Services; +using SharpClaw.Code.Protocol.Models; + +namespace SharpClaw.Code.IntegrationTests.Memory; + +/// Protects approved loading, evaluated project properties, SDK generators, and rename validation. +public sealed class SemanticReviewRegressionTests +{ + /// SDK generators supply real symbols, and generated references cannot cause an unsafe successful rename. + [Fact] + public async Task Sdk_generators_supply_diagnostics_and_rename_is_validated_against_generated_members() + { + using var fixture = await DotNetFixtureWorkspace.CreateAsync(); + await File.WriteAllTextAsync(Path.Combine(fixture.Root, "Library", "Serialization.cs"), """ + using System.Text.Json; + using System.Text.Json.Serialization; + using System.Text.RegularExpressions; + namespace Billing; + public sealed record Shipment(int Id); + [JsonSerializable(typeof(Shipment))] + public partial class ShipmentJsonContext : JsonSerializerContext { } + public static partial class Expressions + { + [GeneratedRegex("invoice")] public static partial Regex InvoicePattern(); + public static string Serialize(Shipment item) => JsonSerializer.Serialize(item, ShipmentJsonContext.Default.Shipment); + } + """); + var request = new DotNetWorkspaceRequest(fixture.Root); + var diagnostics = await fixture.Service.GetDiagnosticsAsync(request, new(Severity: "error"), fixture.Authorization, CancellationToken.None); + diagnostics.Diagnostics.Should().BeEmpty(string.Join(";", diagnostics.Diagnostics.Select(item => item.Message))); + var plan = await fixture.Service.PlanRenameAsync(request, new(Name: "Shipment", NewName: "Delivery"), null, CancellationToken.None); + if (plan.Errors.Length > 0) { plan.Changes.Should().BeEmpty(); return; } + plan.Changes.Should().NotBeEmpty(); + foreach (var change in plan.Changes) await File.WriteAllTextAsync(Path.Combine(fixture.Root, change.Path), change.ContentAfter); + var build = await new ProcessRunner(new SystemClock()).RunAsync(new("dotnet", ["build", "Fixture.slnx", "--no-restore", "--disable-build-servers"], fixture.Root, null), CancellationToken.None); + build.ExitCode.Should().Be(0, build.StandardOutput + build.StandardError); + } + + /// Properties imported from Directory.Build.props decide test classification and redirected asset invalidation. + [Fact] + public async Task Evaluated_test_flags_and_redirected_assets_are_honored_and_fingerprinted() + { + using var fixture = await DotNetFixtureWorkspace.CreateAsync(includeTests: true); + await File.WriteAllTextAsync(Path.Combine(fixture.Root, "Directory.Build.props"), """ + + true + false + + """); + var projectPath = Path.Combine(fixture.Root, "Fixture.Tests", "Fixture.Tests.csproj"); + await File.WriteAllTextAsync(projectPath, (await File.ReadAllTextAsync(projectPath)).Replace("true", "false")); + await fixture.RestoreAsync(); + // Remove legacy assets to prove the loader uses the evaluated path. + foreach (var project in new[] { "Library", "Consumer", "Fixture.Tests" }) Directory.Delete(Path.Combine(fixture.Root, project, "obj"), true); + var request = new DotNetWorkspaceRequest(fixture.Root); + var summary = await fixture.Service.InspectSolutionAsync(request, fixture.Authorization, CancellationToken.None); + summary.Status.Should().Be(DotNetWorkspaceStatus.Ready, string.Join(";", summary.Messages)); + summary.Projects.Should().OnlyContain(project => !project.IsTestProject); + (await fixture.Service.InspectSolutionAsync(request, null, CancellationToken.None)).Status.Should().Be(DotNetWorkspaceStatus.Ready); + var assets = Directory.GetFiles(Path.Combine(fixture.Root, "artifacts"), "project.assets.json", SearchOption.AllDirectories).First(); + await File.AppendAllTextAsync(assets, "\n"); + (await fixture.Service.InspectSolutionAsync(request, null, CancellationToken.None)).Status.Should().Be(DotNetWorkspaceStatus.PermissionDenied); + } + + /// A pending human decision neither blocks unrelated calls nor prevents disposal. + [Fact] + public async Task Pending_authorization_releases_global_gate_and_does_not_block_disposal() + { + using var fixture = await DotNetFixtureWorkspace.CreateAsync(); + var approval = new PendingAuthorization(); + var pending = fixture.Service.InspectSolutionAsync(new(fixture.Root), approval, CancellationToken.None); + await approval.Entered.Task.WaitAsync(TimeSpan.FromSeconds(10)); + try + { + var other = await fixture.Service.InspectSolutionAsync(new(fixture.Root, "Library/Library.csproj"), null, CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(5)); + other.Status.Should().Be(DotNetWorkspaceStatus.PermissionDenied); + await Task.Run(fixture.Service.Dispose).WaitAsync(TimeSpan.FromSeconds(5)); + } + finally { approval.Answer.TrySetResult(true); } + Func completed = async () => await pending; + await completed.Should().ThrowAsync(); + } + + /// Unrelated inaccessible directories do not break the source fingerprint. + [Fact] + public async Task Inaccessible_directory_is_skipped_during_fingerprinting() + { + using var fixture = await DotNetFixtureWorkspace.CreateAsync(); + var inaccessible = Path.Combine(fixture.Root, "pgdata"); + Directory.CreateDirectory(inaccessible); + var files = new FaultingFileSystem { BeforeEnumerate = path => { if (path.EndsWith("pgdata", StringComparison.Ordinal)) throw new UnauthorizedAccessException("Injected unreadable bind mount"); } }; + var paths = new PathService(); + using var service = new DotNetWorkspaceSemanticService(files, paths, new DotNetWorkspaceTargetResolver(files, paths)); + var result = await service.InspectSolutionAsync(new(fixture.Root), fixture.Authorization, CancellationToken.None); + result.Status.Should().Be(DotNetWorkspaceStatus.Ready, string.Join(";", result.Messages)); + } + + /// Existing errors whose messages change with the symbol do not falsely reject a rename. + [Fact] + public async Task Rename_matches_existing_errors_by_mapped_location_instead_of_message() + { + using var fixture = await DotNetFixtureWorkspace.CreateAsync(); + await File.AppendAllTextAsync(Path.Combine(fixture.Root, "Library", "Invoice.cs"), "\npublic sealed class BrokenUse { public void Bad() { IInvoiceService service = null!; service.Missing(); } }\n"); + var plan = await fixture.Service.PlanRenameAsync(new(fixture.Root), new(Name: "IInvoiceService", NewName: "ILongerBillingService"), fixture.Authorization, CancellationToken.None); + plan.Errors.Should().BeEmpty(); + plan.Changes.Should().NotBeEmpty(); + } + + /// Excluded custom analyzer references make limitations visible and disable unverified rename. + [Fact] + public async Task Custom_analyzers_are_excluded_reported_and_block_rename() + { + using var fixture = await DotNetFixtureWorkspace.CreateAsync(); + var project = Path.Combine(fixture.Root, "Library", "Library.csproj"); + var xml = XDocument.Load(project); + xml.Root!.Add(new XElement("ItemGroup", new XElement("Analyzer", new XAttribute("Include", typeof(SemanticReviewRegressionTests).Assembly.Location)))); + xml.Save(project); + var diagnostics = await fixture.Service.GetDiagnosticsAsync(new(fixture.Root), new(Severity: "error"), fixture.Authorization, CancellationToken.None); + diagnostics.Messages.Should().Contain(message => message.Contains("excluded", StringComparison.Ordinal)); + var plan = await fixture.Service.PlanRenameAsync(new(fixture.Root), new(Name: "IInvoiceService", NewName: "IBillingService"), null, CancellationToken.None); + plan.Changes.Should().BeEmpty(); + plan.Errors.Should().Contain(error => error.Contains("cannot be verified", StringComparison.Ordinal)); + } + + private sealed class PendingAuthorization : IDotNetWorkspaceEvaluationAuthorization + { + internal TaskCompletionSource Entered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + internal TaskCompletionSource Answer { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public Task AuthorizeAsync(DotNetWorkspaceRequest request, CancellationToken cancellationToken) { Entered.TrySetResult(); return Answer.Task.WaitAsync(cancellationToken); } + } +} diff --git a/tests/SharpClaw.Code.IntegrationTests/Verification/AutomaticVerificationTests.cs b/tests/SharpClaw.Code.IntegrationTests/Verification/AutomaticVerificationTests.cs index ef33c81..8ebde04 100644 --- a/tests/SharpClaw.Code.IntegrationTests/Verification/AutomaticVerificationTests.cs +++ b/tests/SharpClaw.Code.IntegrationTests/Verification/AutomaticVerificationTests.cs @@ -35,6 +35,7 @@ public async Task Repair_budget_is_exact_and_failed_turn_is_truthful_and_reversi var result = await runtime.RunPromptAsync(Request(fixture.Root, budget), CancellationToken.None); agent.Calls.Should().Be(expectedCalls); verification.Calls.Should().Be(expectedCalls); + verification.Requests.Should().OnlyContain(request => request.TimeoutSeconds == 900); result.Verification!.Status.Should().Be(VerificationStatus.Failed); result.Verification.RepairAttempts.Should().Be(budget); result.Session.State.Should().Be(SessionLifecycleState.Failed); @@ -139,7 +140,7 @@ public async Task Failed_repair_agent_preserves_all_edits() } private static RunPromptRequest Request(string root, int budget) => new("make the change", null, root, PermissionMode.DangerFullAccess, OutputFormat.Json, - new() { [SharpClawWorkflowMetadataKeys.VerificationEnabled] = "true", [SharpClawWorkflowMetadataKeys.VerificationMaxRepairIterations] = budget.ToString() }, AgentId: "fixture-agent", IsInteractive: false); + new() { [SharpClawWorkflowMetadataKeys.VerificationEnabled] = "true", [SharpClawWorkflowMetadataKeys.VerificationMaxRepairIterations] = budget.ToString(), [SharpClawWorkflowMetadataKeys.VerificationTimeoutSeconds] = "900" }, AgentId: "fixture-agent", IsInteractive: false); private static ServiceProvider Provider(RecordingAgent agent, FixedVerification verification) { var services = new ServiceCollection(); @@ -181,9 +182,11 @@ private sealed class FixedVerification : IVerificationService { internal int Calls { get; private set; } internal int PassAt { get; set; } = int.MaxValue; + internal List Requests { get; } = []; public Task VerifyAsync(VerificationRequest request, CancellationToken cancellationToken) { Calls++; + Requests.Add(request); var status = Calls >= PassAt ? VerificationStatus.Passed : VerificationStatus.Failed; var now = DateTimeOffset.UtcNow; return Task.FromResult(new VerificationRunReport(request.WorkspaceRoot, "Fixture.csproj", request.Scope, status, status == VerificationStatus.Passed ? VerificationFailureReason.None : VerificationFailureReason.BuildFailed, now, now, 0, ["Fixture.csproj"], new(status, [], [new("Source.cs", 1, 1, "CS1002", "error", "; expected", "Fixture.csproj")]), null, null, "fixture check")); diff --git a/tests/SharpClaw.Code.IntegrationTests/Verification/VerificationServiceTests.cs b/tests/SharpClaw.Code.IntegrationTests/Verification/VerificationServiceTests.cs index 2e2b6db..b64d56c 100644 --- a/tests/SharpClaw.Code.IntegrationTests/Verification/VerificationServiceTests.cs +++ b/tests/SharpClaw.Code.IntegrationTests/Verification/VerificationServiceTests.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.DependencyInjection; using SharpClaw.Code.Infrastructure.Abstractions; using SharpClaw.Code.Infrastructure.Models; +using SharpClaw.Code.Infrastructure.Services; using SharpClaw.Code.IntegrationTests.Fixtures; using SharpClaw.Code.Memory.Abstractions; using SharpClaw.Code.Protocol.Models; @@ -78,6 +79,57 @@ public async Task Process_failure_missing_reports_timeout_and_no_target_are_dist var action = () => service.VerifyAsync(new(fixture.Root), cancellation.Token); await action.Should().ThrowAsync(); } + + /// Warnings and incidental paths cannot suppress compilation repair by impersonating missing prerequisites. + [Theory] + [InlineData("/dotnet/sdk/10.0.100/Microsoft.Common.targets: warning NU1603: Package was not found.\nFile.cs(1,1): error CS1002: ; expected", VerificationFailureReason.BuildFailed)] + [InlineData("warning NU1603: project.assets.json package was not found.\nFile.cs(1,1): error CS1002: ; expected", VerificationFailureReason.BuildFailed)] + [InlineData("error NETSDK1004: Assets file is missing.", VerificationFailureReason.RestoreRequired)] + [InlineData("error NETSDK1045: Current SDK does not support the target framework.", VerificationFailureReason.SdkUnavailable)] + [InlineData("MSBUILD : error MSB4236: The SDK specified could not be found.", VerificationFailureReason.SdkUnavailable)] + public async Task Failure_classification_uses_error_codes(string output, VerificationFailureReason expected) + { + using var fixture = await DotNetFixtureWorkspace.CreateAsync(); + using var provider = Provider(fixture, new ControlledRunner { Fail = true, Output = output }); + (await provider.GetRequiredService().VerifyAsync(new(fixture.Root, VerificationScope.Build), CancellationToken.None)).Reason.Should().Be(expected); + } + + /// Multi-targeted projects are invoked once and all TRX files are counted exactly once. + [Fact] + public async Task Multi_targeted_test_project_runs_once_per_project_path() + { + using var fixture = await DotNetFixtureWorkspace.CreateAsync(includeTests: true); + var project = Path.Combine(fixture.Root, "Fixture.Tests", "Fixture.Tests.csproj"); + await File.WriteAllTextAsync(project, (await File.ReadAllTextAsync(project)).Replace("net10.0", "net10.0;net10.0-windows")); + await fixture.RestoreAsync(); + using var provider = Provider(fixture); + var result = await provider.GetRequiredService().VerifyAsync(new(fixture.Root, VerificationScope.All), CancellationToken.None); + result.Status.Should().Be(VerificationStatus.Passed, result.Summary); + result.Tests!.Steps.Should().ContainSingle(); + result.Tests.Total.Should().Be(2); + result.AffectedProjects.Should().OnlyHaveUniqueItems(); + } + + /// Workspace builds lasting more than ten seconds serialize instead of failing with an IO exception. + [Fact] + public async Task Workspace_lock_waits_beyond_filesystem_retry_window_and_is_bounded_by_request_timeout() + { + using var fixture = await DotNetFixtureWorkspace.CreateAsync(); + using var provider = Provider(fixture, new ControlledRunner()); + var service = provider.GetRequiredService(); + var handle = await new LocalFileSystem().AcquireExclusiveFileLockAsync(Path.Combine(fixture.Root, ".sharpclaw", "locks", "verification.lock")); + try + { + (await service.VerifyAsync(new(fixture.Root, VerificationScope.Build, TimeoutSeconds: 1), CancellationToken.None)).Reason.Should().Be(VerificationFailureReason.Timeout); + var waiting = service.VerifyAsync(new(fixture.Root, VerificationScope.Build, TimeoutSeconds: 30), CancellationToken.None); + await Task.Delay(TimeSpan.FromSeconds(11)); + waiting.IsCompleted.Should().BeFalse(); + await handle.DisposeAsync(); + handle = null!; + (await waiting).Status.Should().Be(VerificationStatus.Passed); + } + finally { if (handle is not null) await handle.DisposeAsync(); } + } private static ServiceProvider Provider(DotNetFixtureWorkspace fixture, IProcessRunner? runner = null) { var services = new ServiceCollection(); @@ -91,11 +143,12 @@ private sealed class ControlledRunner : IProcessRunner internal List Requests { get; } = []; internal bool Fail { get; set; } internal bool Wait { get; set; } + internal string Output { get; set; } = ""; public async Task RunAsync(ProcessRunRequest request, CancellationToken cancellationToken) { Requests.Add(request); if (Wait) await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); - return new(Fail ? 1 : 0, "", "", DateTimeOffset.UtcNow, DateTimeOffset.UtcNow); + return new(Fail ? 1 : 0, Output, "", DateTimeOffset.UtcNow, DateTimeOffset.UtcNow); } } } diff --git a/tests/SharpClaw.Code.Mcp.FixtureServer/SharpClaw.Code.Mcp.FixtureServer.csproj b/tests/SharpClaw.Code.Mcp.FixtureServer/SharpClaw.Code.Mcp.FixtureServer.csproj index d78429c..9e16ec4 100644 --- a/tests/SharpClaw.Code.Mcp.FixtureServer/SharpClaw.Code.Mcp.FixtureServer.csproj +++ b/tests/SharpClaw.Code.Mcp.FixtureServer/SharpClaw.Code.Mcp.FixtureServer.csproj @@ -9,9 +9,7 @@ - - - + diff --git a/tests/SharpClaw.Code.UnitTests/Runtime/SharpClawConfigServiceTests.cs b/tests/SharpClaw.Code.UnitTests/Runtime/SharpClawConfigServiceTests.cs index af70fb1..8a01ce3 100644 --- a/tests/SharpClaw.Code.UnitTests/Runtime/SharpClawConfigServiceTests.cs +++ b/tests/SharpClaw.Code.UnitTests/Runtime/SharpClawConfigServiceTests.cs @@ -83,7 +83,7 @@ await File.WriteAllTextAsync( var snapshot = await service.GetConfigAsync(workspace, CancellationToken.None); snapshot.Document.ShareMode.Should().Be(ShareMode.Disabled); - snapshot.Document.Verification.Should().BeEquivalentTo(new VerificationOptions(true, VerificationScope.All, true, 2, false)); + snapshot.Document.Verification.Should().BeEquivalentTo(new VerificationOptions(true, VerificationScope.All, true, 2, false, 600)); snapshot.Document.DefaultAgentId.Should().Be("workspace-agent"); snapshot.Document.ConnectLinks.Should().ContainSingle(link => link.Target == "anthropic"); snapshot.Document.Agents.Should().NotBeNull(); diff --git a/tests/SharpClaw.Code.UnitTests/Verification/VerificationDiagnosticsCacheTests.cs b/tests/SharpClaw.Code.UnitTests/Verification/VerificationDiagnosticsCacheTests.cs new file mode 100644 index 0000000..2ba7db4 --- /dev/null +++ b/tests/SharpClaw.Code.UnitTests/Verification/VerificationDiagnosticsCacheTests.cs @@ -0,0 +1,33 @@ +using FluentAssertions; +using SharpClaw.Code.Infrastructure.Abstractions; +using SharpClaw.Code.Infrastructure.Services; +using SharpClaw.Code.Protocol.Models; +using SharpClaw.Code.Runtime.Verification; + +namespace SharpClaw.Code.UnitTests.Verification; + +public sealed class VerificationDiagnosticsCacheTests +{ + [Fact] + public void Tests_only_or_prerequisite_reports_do_not_erase_build_errors_and_out_of_order_reports_are_ignored() + { + var now = DateTimeOffset.UtcNow; + var root = Path.GetFullPath("workspace"); + var cache = new VerificationDiagnosticsCache(new PathService(), new FixedClock(now)); + var build = new VerificationBuildReport(VerificationStatus.Failed, [], [new("File.cs", 1, 1, "CS1002", "error", "; expected", "Project")]); + var broken = new VerificationRunReport(root, "Project", VerificationScope.Build, VerificationStatus.Failed, VerificationFailureReason.BuildFailed, now, now, 0, [], build, null, null, "broken"); + cache.Store(broken); + cache.Store(broken with { Scope = VerificationScope.Tests, Build = null, CompletedAtUtc = now.AddSeconds(1) }); + cache.Store(broken with { Reason = VerificationFailureReason.LoadFailed, Build = null, CompletedAtUtc = now.AddSeconds(2) }); + cache.Store(broken with { CompletedAtUtc = now.AddSeconds(-1), Build = new(VerificationStatus.Passed, [], []) }); + cache.Get(root).Should().Be(broken); + var fixedBuild = broken with { CompletedAtUtc = now.AddSeconds(3), Build = new(VerificationStatus.Passed, [], []) }; + cache.Store(fixedBuild); + cache.Get(root).Should().Be(fixedBuild); + } + + private sealed class FixedClock(DateTimeOffset now) : ISystemClock + { + public DateTimeOffset UtcNow => now; + } +} diff --git a/tests/SharpClaw.Code.UnitTests/Verification/VerificationParserTests.cs b/tests/SharpClaw.Code.UnitTests/Verification/VerificationParserTests.cs index 6608f45..a6e415e 100644 --- a/tests/SharpClaw.Code.UnitTests/Verification/VerificationParserTests.cs +++ b/tests/SharpClaw.Code.UnitTests/Verification/VerificationParserTests.cs @@ -29,6 +29,9 @@ public void Trx_parses_namespace_and_bounded_failures_and_rejects_entities() result.Cases.Should().ContainSingle().Which.Message.Should().Be("wrong value"); parser.Parse("]>&x;", "Tests", null).Reason.Should().Be(VerificationFailureReason.TestResultsMissing); parser.Parse("", "Tests", null).Reason.Should().Be(VerificationFailureReason.TestResultsMissing); + var empty = parser.Parse("", "Tests", null); + empty.Status.Should().Be(VerificationStatus.Skipped); + empty.Reason.Should().Be(VerificationFailureReason.NoTestProjects); } [Fact] diff --git a/tests/SharpClaw.Code.UnitTests/Verification/VerificationPolicyTests.cs b/tests/SharpClaw.Code.UnitTests/Verification/VerificationPolicyTests.cs index eb350bc..79b084a 100644 --- a/tests/SharpClaw.Code.UnitTests/Verification/VerificationPolicyTests.cs +++ b/tests/SharpClaw.Code.UnitTests/Verification/VerificationPolicyTests.cs @@ -22,12 +22,22 @@ public async Task Defaults_are_disabled_and_metadata_overrides_configuration() [InlineData("sharpclaw.verification.maxRepairIterations", "6")] [InlineData("sharpclaw.verification.scope", "unknown")] [InlineData("sharpclaw.verification.enabled", "yes")] + [InlineData("sharpclaw.verification.timeoutSeconds", "0")] + [InlineData("sharpclaw.verification.timeoutSeconds", "3601")] + [InlineData("sharpclaw.verification.timeoutSeconds", "slow")] public async Task Invalid_metadata_is_rejected_before_agent_execution(string key, string value) { var resolver = new VerificationPolicyResolver(new Configuration(null)); var action = () => resolver.ResolveAsync("workspace", new Dictionary { [key] = value }, CancellationToken.None); await action.Should().ThrowAsync(); } + [Fact] + public async Task Automatic_timeout_is_configurable_and_metadata_takes_precedence() + { + var resolver = new VerificationPolicyResolver(new Configuration(new(TimeoutSeconds: 900))); + (await resolver.ResolveAsync("workspace", null, CancellationToken.None)).TimeoutSeconds.Should().Be(900); + (await resolver.ResolveAsync("workspace", new Dictionary { [SharpClawWorkflowMetadataKeys.VerificationTimeoutSeconds] = "1800" }, CancellationToken.None)).TimeoutSeconds.Should().Be(1800); + } private sealed class Configuration(VerificationOptions? options) : ISharpClawConfigService { public Task GetConfigAsync(string workspaceRoot, CancellationToken cancellationToken) => Task.FromResult(new SharpClawConfigSnapshot(workspaceRoot, null, null, new(ShareMode.Manual, null, null, null, null, null, null, Verification: options))); From 08b0cda19e51a9b4da3fb6801d91b529e72abdab Mon Sep 17 00:00:00 2001 From: telli Date: Thu, 15 Oct 2026 18:14:56 -0700 Subject: [PATCH 3/4] fix: release workspace SQLite pools when hosts shut down --- ...elligence-verification-mcp-distribution.md | 1 + .../Services/SqliteWorkspaceKnowledgeStore.cs | 31 ++++++++++++--- .../Server/SqliteUsageMeteringStore.cs | 29 +++++++++++--- .../WorkspaceDatabaseLifecycleTests.cs | 38 +++++++++++++++++++ .../ParityScenarioTests.cs | 25 +++++++++--- 5 files changed, 108 insertions(+), 16 deletions(-) create mode 100644 tests/SharpClaw.Code.IntegrationTests/Runtime/WorkspaceDatabaseLifecycleTests.cs diff --git a/docs/superpowers/plans/2026-10-06-dotnet-intelligence-verification-mcp-distribution.md b/docs/superpowers/plans/2026-10-06-dotnet-intelligence-verification-mcp-distribution.md index e99c45e..eb4a5d0 100644 --- a/docs/superpowers/plans/2026-10-06-dotnet-intelligence-verification-mcp-distribution.md +++ b/docs/superpowers/plans/2026-10-06-dotnet-intelligence-verification-mcp-distribution.md @@ -438,4 +438,5 @@ No branches, commits, PRs, issues, or production releases should be created as p - Semantic loads now read evaluated test flags and restore paths, retain installed SDK generators, expose excluded custom-generator limitations, and reject unverified rename. Rename compares compiler errors by code and mapped location. Approval waits release the global cache gate while concurrent calls share one approved load; failed loads always dispose their build host, and unrelated inaccessible directories are skipped. - Verification now serializes through the configured lock deadline, runs multi-targeted project paths once, classifies prerequisite errors by diagnostic code, preserves cached build diagnostics across test-only/prerequisite failures, and supports a validated configurable automatic timeout (600 seconds by default). Empty valid TRX results are explicitly skipped. Release package smoke accepts and checks the exact prepacked release artifacts. - Local follow-up validation: warning-free Release solution/example build; 414 passing tests (280 unit, 114 integration, 20 parity); coverage above the existing 38 percent floor; all four agent scenarios and their gates; DocFX with warnings as errors; all 22 exact prepacked artifacts installed successfully through an isolated clean SDK consumer and CLI tool smoke. Cross-platform PR and merged-head checks remain the remote merge gates. +- Windows CI follow-up: knowledge and usage stores release their own idle SQLite pools on host disposal, allowing database files to be removed on Windows. A host lifecycle regression checks exclusive database access after shutdown. Cancellation parity now observes startup failures, always cancels/drains its task, and uses the existing longer Windows startup allowance. The 23 focused lifecycle/HTTP/verification tests and all 20 parity cases pass locally before the next cross-platform CI run. - Release acceptance still pending externally: win-x64, win-arm64, linux-x64, linux-arm64 and osx-x64 must execute their configured matching CI jobs. No cross-publish is counted as runtime acceptance, and no packages, tag, commit, PR or GitHub release were published by this implementation run. diff --git a/src/SharpClaw.Code.Memory/Services/SqliteWorkspaceKnowledgeStore.cs b/src/SharpClaw.Code.Memory/Services/SqliteWorkspaceKnowledgeStore.cs index 41e319a..06cbc26 100644 --- a/src/SharpClaw.Code.Memory/Services/SqliteWorkspaceKnowledgeStore.cs +++ b/src/SharpClaw.Code.Memory/Services/SqliteWorkspaceKnowledgeStore.cs @@ -1,5 +1,6 @@ using System.Globalization; using System.Data.Common; +using System.Collections.Concurrent; using Microsoft.Data.Sqlite; using SharpClaw.Code.Infrastructure.Abstractions; using SharpClaw.Code.Memory.Abstractions; @@ -14,8 +15,9 @@ namespace SharpClaw.Code.Memory.Services; public sealed class SqliteWorkspaceKnowledgeStore( IFileSystem fileSystem, IPathService pathService, - IRuntimeStoragePathResolver storagePathResolver) : IWorkspaceKnowledgeStore + IRuntimeStoragePathResolver storagePathResolver) : IWorkspaceKnowledgeStore, IDisposable { + private readonly ConcurrentDictionary connectionPools = new(StringComparer.Ordinal); private const string WorkspaceKnowledgeDirectoryName = "knowledge"; private const string WorkspaceKnowledgeFileName = "knowledge.db"; private const string UserMemoryFileName = "user-memory.db"; @@ -464,15 +466,32 @@ private async Task OpenConnectionAsync(string dbPath, Cancella fileSystem.CreateDirectory(directory); } - var connection = new SqliteConnection(new SqliteConnectionStringBuilder + var connectionString = new SqliteConnectionStringBuilder { DataSource = dbPath, Mode = SqliteOpenMode.ReadWriteCreate, Cache = SqliteCacheMode.Shared, - }.ToString()); - await connection.OpenAsync(cancellationToken).ConfigureAwait(false); - await EnsureSchemaAsync(connection, cancellationToken).ConfigureAwait(false); - return connection; + }.ToString(); + connectionPools.TryAdd(connectionString, 0); + var connection = new SqliteConnection(connectionString); + try + { + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + await EnsureSchemaAsync(connection, cancellationToken).ConfigureAwait(false); + return connection; + } + catch { await connection.DisposeAsync().ConfigureAwait(false); throw; } + } + + /// Releases this store's idle database handles when its host shuts down, including on Windows. + public void Dispose() + { + foreach (var connectionString in connectionPools.Keys) + { + using var connection = new SqliteConnection(connectionString); + SqliteConnection.ClearPool(connection); + } + connectionPools.Clear(); } private static async Task EnsureSchemaAsync(SqliteConnection connection, CancellationToken cancellationToken) diff --git a/src/SharpClaw.Code.Runtime/Server/SqliteUsageMeteringStore.cs b/src/SharpClaw.Code.Runtime/Server/SqliteUsageMeteringStore.cs index 1322e84..36cb5fc 100644 --- a/src/SharpClaw.Code.Runtime/Server/SqliteUsageMeteringStore.cs +++ b/src/SharpClaw.Code.Runtime/Server/SqliteUsageMeteringStore.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Collections.Concurrent; using Microsoft.Data.Sqlite; using SharpClaw.Code.Infrastructure.Abstractions; using SharpClaw.Code.Protocol.Models; @@ -12,8 +13,9 @@ namespace SharpClaw.Code.Runtime.Server; public sealed class SqliteUsageMeteringStore( IFileSystem fileSystem, IPathService pathService, - IRuntimeStoragePathResolver storagePathResolver) : IUsageMeteringStore + IRuntimeStoragePathResolver storagePathResolver) : IUsageMeteringStore, IDisposable { + private readonly ConcurrentDictionary connectionPools = new(StringComparer.Ordinal); /// public async Task AppendAsync(string workspaceRoot, UsageMeteringRecord record, CancellationToken cancellationToken) { @@ -138,10 +140,27 @@ private async Task OpenConnectionAsync(string workspaceRoot, C ?? storagePathResolver.GetTelemetryRoot(normalizedWorkspace); fileSystem.CreateDirectory(telemetryRoot); - var connection = new SqliteConnection($"Data Source={dbPath}"); - await connection.OpenAsync(cancellationToken).ConfigureAwait(false); - await EnsureSchemaAsync(connection, cancellationToken).ConfigureAwait(false); - return connection; + var connectionString = new SqliteConnectionStringBuilder { DataSource = dbPath }.ToString(); + connectionPools.TryAdd(connectionString, 0); + var connection = new SqliteConnection(connectionString); + try + { + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + await EnsureSchemaAsync(connection, cancellationToken).ConfigureAwait(false); + return connection; + } + catch { await connection.DisposeAsync().ConfigureAwait(false); throw; } + } + + /// Releases this host's idle metering database handles without clearing unrelated workspace pools. + public void Dispose() + { + foreach (var connectionString in connectionPools.Keys) + { + using var connection = new SqliteConnection(connectionString); + SqliteConnection.ClearPool(connection); + } + connectionPools.Clear(); } private static async Task EnsureSchemaAsync(SqliteConnection connection, CancellationToken cancellationToken) diff --git a/tests/SharpClaw.Code.IntegrationTests/Runtime/WorkspaceDatabaseLifecycleTests.cs b/tests/SharpClaw.Code.IntegrationTests/Runtime/WorkspaceDatabaseLifecycleTests.cs new file mode 100644 index 0000000..cb9b07b --- /dev/null +++ b/tests/SharpClaw.Code.IntegrationTests/Runtime/WorkspaceDatabaseLifecycleTests.cs @@ -0,0 +1,38 @@ +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using SharpClaw.Code.Memory.Abstractions; +using SharpClaw.Code.Runtime; +using SharpClaw.Code.Telemetry.Abstractions; + +namespace SharpClaw.Code.IntegrationTests.Runtime; + +/// Verifies database handles follow the host lifecycle, including Windows file sharing rules. +public sealed class WorkspaceDatabaseLifecycleTests +{ + /// A disposed host releases its knowledge and metering databases for exclusive access and workspace removal. + [Fact] + public async Task Host_disposal_releases_knowledge_and_usage_database_handles() + { + var root = Path.Combine(Path.GetTempPath(), "sharpclaw-db-lifecycle-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + try + { + await File.WriteAllTextAsync(Path.Combine(root, "Source.cs"), "public class Source { }"); + using (var provider = new ServiceCollection().AddSharpClawRuntime().BuildServiceProvider()) + { + await provider.GetRequiredService().RefreshAsync(root, CancellationToken.None); + await provider.GetRequiredService().GetSummaryAsync(root, new(), CancellationToken.None); + } + var databases = Directory.GetFiles(root, "*.db", SearchOption.AllDirectories); + databases.Should().HaveCount(2); + foreach (var database in databases) + { + using var exclusive = new FileStream(database, FileMode.Open, FileAccess.ReadWrite, FileShare.None); + exclusive.Length.Should().BeGreaterThan(0); + } + Directory.Delete(root, recursive: true); + Directory.Exists(root).Should().BeFalse(); + } + finally { if (Directory.Exists(root)) Directory.Delete(root, recursive: true); } + } +} diff --git a/tests/SharpClaw.Code.ParityHarness/ParityScenarioTests.cs b/tests/SharpClaw.Code.ParityHarness/ParityScenarioTests.cs index c00d465..1be13b0 100644 --- a/tests/SharpClaw.Code.ParityHarness/ParityScenarioTests.cs +++ b/tests/SharpClaw.Code.ParityHarness/ParityScenarioTests.cs @@ -327,16 +327,31 @@ private async Task RunPromptWithCancelAfterTurnStartAsync( { using var cts = new CancellationTokenSource(); var runTask = runtime.RunPromptAsync(request, cts.Token); - await WaitForActiveTurnAsync(store, CancellationToken.None); - cts.CancelAfter(cancelAfter); - await runTask.ConfigureAwait(false); + try + { + await WaitForActiveTurnAsync(store, runTask, CancellationToken.None); + cts.CancelAfter(cancelAfter); + await runTask.ConfigureAwait(false); + } + finally + { + cts.Cancel(); + try { await runTask.ConfigureAwait(false); } + catch (OperationCanceledException) { } + } } - private async Task WaitForActiveTurnAsync(ISessionStore store, CancellationToken cancellationToken) + private async Task WaitForActiveTurnAsync(ISessionStore store, Task runTask, CancellationToken cancellationToken) { // Startup is separate from the cancellation deadline and can be slow on loaded Windows runners. - for (var attempt = 0; attempt < 600; attempt++) + var maxAttempts = OperatingSystem.IsWindows() ? 1800 : 600; + for (var attempt = 0; attempt < maxAttempts; attempt++) { + if (runTask.IsCompleted) + { + await runTask.ConfigureAwait(false); + throw new InvalidOperationException("The parity runtime completed before an active turn could be observed."); + } var session = await store.GetLatestAsync(_workspace, cancellationToken).ConfigureAwait(false); if (!string.IsNullOrWhiteSpace(session?.ActiveTurnId)) { From 3e4d1078c008c467d2237cd09e95ba6c476f7afc Mon Sep 17 00:00:00 2001 From: telli Date: Thu, 15 Oct 2026 18:17:56 -0700 Subject: [PATCH 4/4] test: observe turn startup without racing session snapshots --- ...t-intelligence-verification-mcp-distribution.md | 1 + .../ParityScenarioTests.cs | 14 ++++++++------ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/plans/2026-10-06-dotnet-intelligence-verification-mcp-distribution.md b/docs/superpowers/plans/2026-10-06-dotnet-intelligence-verification-mcp-distribution.md index eb4a5d0..8bb6434 100644 --- a/docs/superpowers/plans/2026-10-06-dotnet-intelligence-verification-mcp-distribution.md +++ b/docs/superpowers/plans/2026-10-06-dotnet-intelligence-verification-mcp-distribution.md @@ -439,4 +439,5 @@ No branches, commits, PRs, issues, or production releases should be created as p - Verification now serializes through the configured lock deadline, runs multi-targeted project paths once, classifies prerequisite errors by diagnostic code, preserves cached build diagnostics across test-only/prerequisite failures, and supports a validated configurable automatic timeout (600 seconds by default). Empty valid TRX results are explicitly skipped. Release package smoke accepts and checks the exact prepacked release artifacts. - Local follow-up validation: warning-free Release solution/example build; 414 passing tests (280 unit, 114 integration, 20 parity); coverage above the existing 38 percent floor; all four agent scenarios and their gates; DocFX with warnings as errors; all 22 exact prepacked artifacts installed successfully through an isolated clean SDK consumer and CLI tool smoke. Cross-platform PR and merged-head checks remain the remote merge gates. - Windows CI follow-up: knowledge and usage stores release their own idle SQLite pools on host disposal, allowing database files to be removed on Windows. A host lifecycle regression checks exclusive database access after shutdown. Cancellation parity now observes startup failures, always cancels/drains its task, and uses the existing longer Windows startup allowance. The 23 focused lifecycle/HTTP/verification tests and all 20 parity cases pass locally before the next cross-platform CI run. +- Cancellation startup synchronization consumes the existing typed in-process turn-start events instead of reading the session snapshot while the runtime rewrites it, avoiding Windows file-sharing races in the test observer. - Release acceptance still pending externally: win-x64, win-arm64, linux-x64, linux-arm64 and osx-x64 must execute their configured matching CI jobs. No cross-publish is counted as runtime acceptance, and no packages, tag, commit, PR or GitHub release were published by this implementation run. diff --git a/tests/SharpClaw.Code.ParityHarness/ParityScenarioTests.cs b/tests/SharpClaw.Code.ParityHarness/ParityScenarioTests.cs index 1be13b0..3c9a7fc 100644 --- a/tests/SharpClaw.Code.ParityHarness/ParityScenarioTests.cs +++ b/tests/SharpClaw.Code.ParityHarness/ParityScenarioTests.cs @@ -6,12 +6,14 @@ using SharpClaw.Code.Permissions.Models; using SharpClaw.Code.Protocol.Commands; using SharpClaw.Code.Protocol.Enums; +using SharpClaw.Code.Protocol.Events; using SharpClaw.Code.Protocol.Models; using SharpClaw.Code.Runtime.Abstractions; using SharpClaw.Code.Sessions.Abstractions; using SharpClaw.Code.Tools.Abstractions; using SharpClaw.Code.Tools.BuiltIn; using SharpClaw.Code.Tools.Models; +using SharpClaw.Code.Telemetry.Abstractions; namespace SharpClaw.Code.ParityHarness; @@ -299,7 +301,7 @@ public async Task Recovery_after_timeout_marks_session_failed() { await RunPromptWithCancelAfterTurnStartAsync( runtime, - store, + provider.GetRequiredService(), new RunPromptRequest( Prompt: "slow", SessionId: null, @@ -321,7 +323,7 @@ await RunPromptWithCancelAfterTurnStartAsync( private async Task RunPromptWithCancelAfterTurnStartAsync( IConversationRuntime runtime, - ISessionStore store, + IRuntimeEventStream stream, RunPromptRequest request, TimeSpan cancelAfter) { @@ -329,7 +331,7 @@ private async Task RunPromptWithCancelAfterTurnStartAsync( var runTask = runtime.RunPromptAsync(request, cts.Token); try { - await WaitForActiveTurnAsync(store, runTask, CancellationToken.None); + await WaitForActiveTurnAsync(stream, runTask, CancellationToken.None); cts.CancelAfter(cancelAfter); await runTask.ConfigureAwait(false); } @@ -341,7 +343,7 @@ private async Task RunPromptWithCancelAfterTurnStartAsync( } } - private async Task WaitForActiveTurnAsync(ISessionStore store, Task runTask, CancellationToken cancellationToken) + private static async Task WaitForActiveTurnAsync(IRuntimeEventStream stream, Task runTask, CancellationToken cancellationToken) { // Startup is separate from the cancellation deadline and can be slow on loaded Windows runners. var maxAttempts = OperatingSystem.IsWindows() ? 1800 : 600; @@ -352,8 +354,8 @@ private async Task WaitForActiveTurnAsync(ISessionStore store, Task runTask, Can await runTask.ConfigureAwait(false); throw new InvalidOperationException("The parity runtime completed before an active turn could be observed."); } - var session = await store.GetLatestAsync(_workspace, cancellationToken).ConfigureAwait(false); - if (!string.IsNullOrWhiteSpace(session?.ActiveTurnId)) + // Poll the typed event stream without opening the snapshot being rewritten by the runtime. + if (stream.GetRecentEnvelopesSnapshot().Any(envelope => envelope.Event is TurnStartedEvent)) { return; }