Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,15 @@ jobs:

- name: Build
run: dotnet build -c Release --no-restore

- name: Pack documentation dependencies
if: matrix.framework == 'net10.0'
run: dotnet pack Respire.slnx -c Release --no-build -p:Version=0.0.0-docs -o artifacts/package/release

- name: Compile documentation snippets
if: matrix.framework == 'net10.0'
shell: pwsh
run: ./scripts/Verify-DocSnippets.ps1 -PackagesPath artifacts/package/release -Version 0.0.0-docs

- name: Run Unit Tests
run: |
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Build results
bin/
obj/
artifacts/
BenchmarkDotNet.Artifacts/
TestResults/

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,7 @@ pass through a Respire serializer.

### Dependency injection

<!-- doc-test-tail-declaration: split-before=public sealed class CartService -->
```csharp
builder.Services.AddRespire(builder.Configuration.GetConnectionString("redis")!);

Expand All @@ -297,6 +298,7 @@ Standalone clients surface setup exceptions directly, while cluster clients wrap
Typed values use reflection-based System.Text.Json metadata by default. For a trimmed or NativeAOT
application, generate metadata for every stored type and pass that context to Respire:

<!-- doc-test-tail-declaration: split-before=[JsonSerializable -->
```csharp
using System.Text.Json.Serialization;
using Respire;
Expand Down
351 changes: 351 additions & 0 deletions scripts/Verify-DocSnippets.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,351 @@
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$PackagesPath,

[Parameter(Mandatory)]
[string]$Version
)

$ErrorActionPreference = 'Stop'

$repositoryRoot = Split-Path $PSScriptRoot -Parent
$resolvedPackagesPath = (Resolve-Path -LiteralPath $PackagesPath).Path
$generatedDirectory = Join-Path $repositoryRoot 'artifacts/doc-tests/generated'
$generatedPath = Join-Path $generatedDirectory 'GeneratedSnippets.g.cs'
$nugetConfigPath = Join-Path $generatedDirectory 'NuGet.config'
$restorePackagesPath = Join-Path $generatedDirectory 'packages'
$projectPath = Join-Path $repositoryRoot 'tests/Respire.DocTests/Respire.DocTests.csproj'
$documentPaths = @(
(Join-Path $repositoryRoot 'README.md')
Get-ChildItem (Join-Path $repositoryRoot 'website/docs') -Recurse -File -Include '*.md', '*.mdx' |
Sort-Object FullName |
ForEach-Object FullName
)

$requiredPackages = @(
'Respire'
'Respire.Extensions.Caching'
'Respire.Extensions.Caching.Hybrid'
'Respire.Extensions.DependencyInjection'
)

foreach ($packageId in $requiredPackages)
{
$packagePath = Join-Path $resolvedPackagesPath "$packageId.$Version.nupkg"
if (-not (Test-Path -LiteralPath $packagePath -PathType Leaf))
{
throw "Documented package '$packageId' was not packed at '$packagePath'."
}
}

$snippets = [System.Collections.Generic.List[object]]::new()
$installPackageIds = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
$projectCommands = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)

foreach ($documentPath in $documentPaths)
{
$relativePath = [IO.Path]::GetRelativePath($repositoryRoot, $documentPath).Replace('\', '/')
$lines = Get-Content -LiteralPath $documentPath
$csharpOrdinal = 0

for ($lineIndex = 0; $lineIndex -lt $lines.Count; $lineIndex++)
{
$fenceMatch = [regex]::Match(
$lines[$lineIndex],
'^(?<indent> {0,3})(?<delimiter>`{3,}|~{3,})(?<info>.*)$')
if (-not $fenceMatch.Success)
{
continue
}

$delimiter = $fenceMatch.Groups['delimiter'].Value
$closingFencePattern = '^ {0,3}' + [regex]::Escape($delimiter[0]) +
"{$($delimiter.Length),}\s*$"
Comment on lines +62 to +64

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Skip contents of non-target fenced blocks

When a non-target fence contains literal documentation examples—such as a four-backtick markdown block demonstrating a triple-backtick csharp fence—the outer fence is recognized here but never consumed because neither later language branch matches. The inner fence is consequently treated as a real snippet and can make CI compile or validate example text that is not an actual public code block. Advance to the compatible closing fence for every recognized opening delimiter, even when its language is not being tested.

Useful? React with 👍 / 👎.

$info = $fenceMatch.Groups['info'].Value.Trim()

if ($info -match '^csharp(?:\s+.*)?$')
{
$csharpOrdinal++
$startLine = $lineIndex + 2
$body = [System.Collections.Generic.List[string]]::new()
for ($lineIndex++; $lineIndex -lt $lines.Count -and $lines[$lineIndex] -notmatch $closingFencePattern; $lineIndex++)
{
$body.Add($lines[$lineIndex])
}

if ($lineIndex -ge $lines.Count)
{
throw "Unclosed C# fence at ${relativePath}:$startLine."
}

$directive = if ($startLine -ge 3) { $lines[$startLine - 3].Trim() } else { '' }
$ignoreReason = $null
if ($directive -match '^<!--\s*doc-test-ignore:\s*(.+?)\s*-->$')
{
$ignoreReason = $Matches[1]
}

$ignored = $null -ne $ignoreReason
if ($directive -match '^<!--\s*doc-test-ignore' -and -not $ignored)
{
throw "Malformed doc-test-ignore directive before ${relativePath}:$startLine. Include a non-empty reason."
}

$mode = 'statements'
$splitBefore = $null
if ($directive -match '^<!--\s*doc-test-declaration:\s*split-before=(.+?)\s*-->$')
{
$mode = 'declaration'
$splitBefore = $Matches[1]
}
elseif ($directive -match '^<!--\s*doc-test-tail-declaration:\s*split-before=(.+?)\s*-->$')
{
$mode = 'tail-declaration'
$splitBefore = $Matches[1]
}
elseif ($directive -match '^<!--\s*doc-test-declaration\s*-->$')
{
$mode = 'declaration'
}
elseif ($directive -match '^<!--\s*doc-test-' -and -not $ignored)
{
throw "Unknown or malformed doc-test directive before ${relativePath}:$startLine."
}

$source = $body -join "`n"
if ($ignored)
{
Write-Host "EXCLUDED $relativePath#$csharpOrdinal ($ignoreReason)"
continue
}

$snippets.Add([pscustomobject]@{
Id = "$relativePath#$csharpOrdinal"
SourcePath = $documentPath.Replace('\', '/')
Line = $startLine
Source = $source
Mode = $mode
SplitBefore = $splitBefore
})

continue
}

if ($info -match '^(?:bash|sh|shell|powershell|pwsh)(?:\s+.*)?$')
{
for ($lineIndex++; $lineIndex -lt $lines.Count -and $lines[$lineIndex] -notmatch $closingFencePattern; $lineIndex++)
{
$command = $lines[$lineIndex].Trim()
if ($command -match '^dotnet (?:add package|package add)\s+([A-Za-z0-9_.-]+)')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate paths in dotnet add reference commands

The current installation samples in website/docs/getting-started.md and website/docs/integrations/dependency-injection.md use dotnet add reference, but this extractor recognizes only dotnet add package; the later project-path branches cover only run and test. Consequently, a renamed or misspelled referenced .csproj remains invisible to the new documentation gate. Extract reference arguments as project paths as well, accounting for the documented path/to/Respire/ placeholder prefix.

Useful? React with 👍 / 👎.

{
[void]$installPackageIds.Add($Matches[1])
}

if ($command -match '^dotnet (?:add reference|reference add)\s+([^\s]+)')
{
$referencePath = $Matches[1].Trim("'`"").Replace('\', '/') -replace
'^path/to/Respire/', ''
[void]$projectCommands.Add($referencePath)
}

if ($command -match '^dotnet run\s+.*--project\s+([^\s]+)')
{
[void]$projectCommands.Add($Matches[1].Trim("'`""))
Comment on lines +152 to +154

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate the project commands that appear in the docs

The current documentation's project paths occur in dotnet test commands in README.md, while this parser only records paths from dotnet run --project. Consequently $projectCommands is empty for all current shell samples, so renaming or removing either documented test project would leave the new path-validation gate green. Extend extraction to the documented dotnet test form, including its positional project argument.

Useful? React with 👍 / 👎.

}
elseif ($command -match '^dotnet test\s+--project\s+([^\s]+)')
{
[void]$projectCommands.Add($Matches[1].Trim("'`""))
}
elseif ($command -match '^dotnet test\s+(?!-)([^\s]+)')
{
[void]$projectCommands.Add($Matches[1].Trim("'`""))
}
}
}
}
}

foreach ($packageId in $installPackageIds)
{
if ($packageId -notin $requiredPackages)
{
throw "Shell sample references unexpected package ID '$packageId'."
}
}

foreach ($relativeProjectPath in $projectCommands)
{
$commandPath = Join-Path $repositoryRoot $relativeProjectPath
$projectExists = $false
if (Test-Path -LiteralPath $commandPath -PathType Leaf)
{
$projectExists = (Get-Item -LiteralPath $commandPath).Extension -in
'.csproj', '.fsproj', '.vbproj'
}
elseif (Test-Path -LiteralPath $commandPath -PathType Container)
{
$projectExists = $null -ne (Get-ChildItem -LiteralPath $commandPath -File |
Where-Object { $_.Extension -in '.csproj', '.fsproj', '.vbproj' } |
Select-Object -First 1)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

if (-not $projectExists)
{
throw "Shell sample references missing project '$relativeProjectPath'."
}
}

New-Item -ItemType Directory -Force -Path $generatedDirectory | Out-Null
if (Test-Path -LiteralPath $restorePackagesPath)
{
Remove-Item -LiteralPath $restorePackagesPath -Recurse -Force
}

$builder = [Text.StringBuilder]::new()
[void]$builder.AppendLine('// <auto-generated />')
[void]$builder.AppendLine('using System.Text.Json.Serialization;')
[void]$builder.AppendLine('using Microsoft.Extensions.Caching.Distributed;')
[void]$builder.AppendLine('using Microsoft.Extensions.Configuration;')
[void]$builder.AppendLine('using Microsoft.Extensions.DependencyInjection;')
[void]$builder.AppendLine('using Microsoft.Extensions.Logging;')
[void]$builder.AppendLine('using OpenTelemetry.Metrics;')
[void]$builder.AppendLine('using OpenTelemetry.Trace;')
[void]$builder.AppendLine('using Respire;')
[void]$builder.AppendLine('using Respire.Extensions.Caching;')
[void]$builder.AppendLine('using Respire.Extensions.Caching.Hybrid;')
[void]$builder.AppendLine('using Respire.Extensions.DependencyInjection;')
[void]$builder.AppendLine('using Respire.Serialization;')
[void]$builder.AppendLine('#pragma warning disable')

$usingPattern = '(?m)^\s*(?:global\s+)?using\s+(?:(?:static\s+)?[A-Za-z_][A-Za-z0-9_.]*|[A-Za-z_][A-Za-z0-9_]*\s*=\s*(?:global::)?[A-Za-z_][A-Za-z0-9_.]*)\s*;\s*(?://.*)?$'
for ($snippetIndex = 0; $snippetIndex -lt $snippets.Count; $snippetIndex++)
{
$snippet = $snippets[$snippetIndex]
$usingDirectives = [regex]::Matches($snippet.Source, $usingPattern) |
ForEach-Object { $_.Value.Trim() -replace '^global\s+', '' }
$source = [regex]::Replace($snippet.Source, $usingPattern, '')
$source = $source -replace '(?m)^\s*#pragma\s+warning\s+(?:disable|restore).*$', ''

$declarationLine = $snippet.Line
$statementLine = $snippet.Line
$sourceSplitIndex = -1
if ($null -ne $snippet.SplitBefore)
{
$originalSplitIndex = $snippet.Source.IndexOf($snippet.SplitBefore, [StringComparison]::Ordinal)
$sourceSplitIndex = $source.IndexOf($snippet.SplitBefore, [StringComparison]::Ordinal)
if ($originalSplitIndex -lt 0 -or $sourceSplitIndex -lt 0)
{
throw "Split marker '$($snippet.SplitBefore)' was not found in $($snippet.Id)."
}

$splitLine = $snippet.Line +
[regex]::Matches($snippet.Source.Substring(0, $originalSplitIndex), "`n").Count
if ($snippet.Mode -eq 'declaration')
{
$statementLine = $splitLine
}
else
{
$declarationLine = $splitLine
}
}

[void]$builder.AppendLine('namespace Respire.DocTests')
[void]$builder.AppendLine('{')
foreach ($usingDirective in $usingDirectives)
{
[void]$builder.AppendLine($usingDirective)
}
[void]$builder.AppendLine("internal sealed partial class Snippet$snippetIndex : SnippetContext")
[void]$builder.AppendLine('{')

if ($snippet.Mode -eq 'declaration')
{
if ($null -eq $snippet.SplitBefore)
{
$declarationSource = $source.TrimEnd()
$source = ''
}
else
{
$declarationSource = $source.Substring(0, $sourceSplitIndex).TrimEnd()
$source = $source.Substring($sourceSplitIndex)
}

[void]$builder.AppendLine("#line $declarationLine `"$($snippet.SourcePath)`"")
foreach ($line in ($declarationSource -split "`n"))
{
[void]$builder.AppendLine(" $line")
}
[void]$builder.AppendLine('#line default')
}
elseif ($snippet.Mode -eq 'tail-declaration')
{
$declarationSource = $source.Substring($sourceSplitIndex).TrimEnd()
$source = $source.Substring(0, $sourceSplitIndex).TrimEnd()
[void]$builder.AppendLine("#line $declarationLine `"$($snippet.SourcePath)`"")
foreach ($line in ($declarationSource -split "`n"))
{
[void]$builder.AppendLine(" $line")
}
[void]$builder.AppendLine('#line default')
}

[void]$builder.AppendLine(' public static async Task RunAsync()')
[void]$builder.AppendLine(' {')
if (-not [string]::IsNullOrWhiteSpace($source))
{
[void]$builder.AppendLine("#line $statementLine `"$($snippet.SourcePath)`"")
foreach ($line in ($source -split "`n"))
{
[void]$builder.AppendLine(" $line")
}
[void]$builder.AppendLine('#line default')
}
[void]$builder.AppendLine(' }')
[void]$builder.AppendLine('}')
[void]$builder.AppendLine('}')
}

[void]$builder.AppendLine('namespace Respire.DocTests')
[void]$builder.AppendLine('{')
[void]$builder.AppendLine('internal static class SnippetCatalog')
[void]$builder.AppendLine('{')
[void]$builder.AppendLine(' public static void Report() =>')
[void]$builder.AppendLine(" Console.WriteLine(`"Compiled $($snippets.Count) C# documentation snippets.`");")
[void]$builder.AppendLine('}')
[void]$builder.AppendLine('}')

[IO.File]::WriteAllText($generatedPath, $builder.ToString())

[IO.File]::WriteAllText(
$nugetConfigPath,
@"
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="local-packages" value="$([Security.SecurityElement]::Escape($resolvedPackagesPath))" />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
</packageSources>
</configuration>
"@)

& dotnet restore $projectPath --configfile $nugetConfigPath --force --packages $restorePackagesPath `
"-p:RespirePackageVersion=$Version" `
"-p:GeneratedSnippetsPath=$generatedPath"

if ($LASTEXITCODE -ne 0)
{
throw "Documentation snippet restore failed with exit code $LASTEXITCODE."
}

& dotnet run --project $projectPath -c Release --no-restore `
"-p:RespirePackageVersion=$Version" `
"-p:GeneratedSnippetsPath=$generatedPath"

if ($LASTEXITCODE -ne 0)
{
throw "Documentation snippet project failed with exit code $LASTEXITCODE."
}
Loading
Loading