<# .SYNOPSIS Produces a syntax-based inventory of every attributed C# test method. .DESCRIPTION Parses tracked C# files under tests/ with the Roslyn assemblies bundled in the active .NET SDK. The report is evidence for the R3 test-truth audit: it records test attributes and traits, static skips, empty-return sites, prerequisite gates reached through same-file helper calls, assertion/throw signals (including same-file helper calls), diagnostic output signals, wall-clock waits, environment-variable dependencies, and source-text reads. This is a candidate generator, not a semantic proof. In particular, an output-only candidate can call a failure-capable helper declared in another file or base class. Every removal or lane change still requires inspection. #> [CmdletBinding()] param( [string]$OutputPath = 'artifacts/test-audit/test-inventory.json' ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $repoRoot = [IO.Path]::GetFullPath((Split-Path -Parent $PSScriptRoot)) $resolvedOutput = if ([IO.Path]::IsPathRooted($OutputPath)) { [IO.Path]::GetFullPath($OutputPath) } else { [IO.Path]::GetFullPath((Join-Path $repoRoot $OutputPath)) } $dotnetCommand = Get-Command dotnet -ErrorAction Stop $dotnetRoot = Split-Path -Parent $dotnetCommand.Source $sdkVersion = (& dotnet --version).Trim() $roslynDirectory = Join-Path $dotnetRoot "sdk/$sdkVersion/Roslyn/bincore" $loadedRoslyn = @([AppDomain]::CurrentDomain.GetAssemblies() | Where-Object { $_.GetName().Name -in @('Microsoft.CodeAnalysis', 'Microsoft.CodeAnalysis.CSharp') }) if ($loadedRoslyn.Count -lt 2) { foreach ($assemblyName in 'Microsoft.CodeAnalysis.dll', 'Microsoft.CodeAnalysis.CSharp.dll') { $assemblyPath = Join-Path $roslynDirectory $assemblyName if (-not (Test-Path -LiteralPath $assemblyPath -PathType Leaf)) { throw "Roslyn assembly not found at '$assemblyPath'." } Add-Type -Path $assemblyPath } } $roslynVersion = [Microsoft.CodeAnalysis.CSharp.CSharpSyntaxTree].Assembly.GetName().Version.ToString() function Get-ShortAttributeName { param([Parameter(Mandatory)]$Attribute) $name = $Attribute.Name.ToString().Split('.')[-1] if ($name.EndsWith('Attribute', [StringComparison]::Ordinal)) { $name = $name.Substring(0, $name.Length - 'Attribute'.Length) } return $name } function Get-NodeLine { param( [Parameter(Mandatory)]$Tree, [Parameter(Mandatory)]$Node ) return $Tree.GetLineSpan($Node.Span).StartLinePosition.Line + 1 } function Get-ContainingClassName { param([Parameter(Mandatory)]$Method) $typeNode = Get-ContainingTypeNode $Method if ($null -ne $typeNode) { return $typeNode.Identifier.Text } return '' } function Get-ContainingTypeNode { param([Parameter(Mandatory)]$Method) $cursor = $Method.Parent while ($null -ne $cursor) { if ($cursor.GetType().Name -in @( 'ClassDeclarationSyntax', 'RecordDeclarationSyntax', 'StructDeclarationSyntax')) { return $cursor } $cursor = $cursor.Parent } return $null } function Get-ReturnGateKind { param([Parameter(Mandatory)][string]$Condition) if ($Condition -match '(?i)(GetEnvironmentVariable|ACDREAM_)') { return 'OptIn' } if ($Condition -match '(?i)(OperatingSystem\.)') { return 'Platform' } if ($Condition -match '(?i)(datDir|datDirectory|Directory\.Exists|File\.Exists|TryOpen|TryBuildScenario|steps is null|csvPath|\.pak)') { return 'ExternalAsset' } return $null } function Get-EmptyReturnSites { param( [Parameter(Mandatory)]$Tree, [Parameter(Mandatory)]$Method ) $sites = [Collections.Generic.List[object]]::new() foreach ($returnNode in @($Method.DescendantNodes() | Where-Object { $_.GetType().Name -eq 'ReturnStatementSyntax' -and $null -eq $_.Expression })) { $condition = '' $cursor = $returnNode.Parent while ($null -ne $cursor -and $cursor -ne $Method) { if ($cursor.GetType().Name -eq 'IfStatementSyntax') { $condition = $cursor.Condition.ToString() break } $cursor = $cursor.Parent } $sites.Add([ordered]@{ Line = Get-NodeLine $Tree $returnNode Condition = $condition GateKind = Get-ReturnGateKind $condition }) } return @($sites) } function Get-InvocationName { param([Parameter(Mandatory)]$Invocation) $expression = $Invocation.Expression if ($expression.GetType().Name -eq 'MemberAccessExpressionSyntax') { return $expression.Name.Identifier.Text } if ($expression.GetType().Name -eq 'IdentifierNameSyntax') { return $expression.Identifier.Text } if ($expression.GetType().Name -eq 'GenericNameSyntax') { return $expression.Identifier.Text } return $expression.ToString() } function Test-DirectFailureSignal { param([Parameter(Mandatory)]$Method) if (@($Method.DescendantNodes() | Where-Object { $_.GetType().Name -in @('ThrowStatementSyntax', 'ThrowExpressionSyntax') }).Count -gt 0) { return $true } foreach ($invocation in @($Method.DescendantNodes() | Where-Object { $_.GetType().Name -eq 'InvocationExpressionSyntax' })) { $expression = $invocation.Expression.ToString() if ($expression -match '(^|\.)Assert(\.|$)' -or $expression -match '(^|\.)(Should|Shouldly)(\.|$)' -or $expression -match '(^|\.)Verify($|\.)') { return $true } } return $false } function Test-DirectOutputSignal { param([Parameter(Mandatory)]$Method) foreach ($invocation in @($Method.DescendantNodes() | Where-Object { $_.GetType().Name -eq 'InvocationExpressionSyntax' })) { $expression = $invocation.Expression.ToString() if ($expression -match '(^|\.)(_?out|output|Console|Debug|Trace)\.(Write|WriteLine)$' -or $expression -match '(^|\.)(WriteDiagnostic|Dump|DumpSteps|Print)(\.|$)') { return $true } } return $false } function Test-RecursiveSignal { param( [Parameter(Mandatory)][string]$MethodKey, [Parameter(Mandatory)][hashtable]$MethodMap, [Parameter(Mandatory)][hashtable]$DirectSignalMap, [Parameter(Mandatory)][hashtable]$CallMap, [Parameter(Mandatory)][hashtable]$Visited ) if ($Visited.ContainsKey($MethodKey)) { return $false } $Visited[$MethodKey] = $true if ($DirectSignalMap[$MethodKey]) { return $true } foreach ($calledName in @($CallMap[$MethodKey])) { foreach ($candidateKey in @($MethodMap.Keys | Where-Object { $_.EndsWith("::$calledName", [StringComparison]::Ordinal) })) { if (Test-RecursiveSignal $candidateKey $MethodMap $DirectSignalMap $CallMap $Visited) { return $true } } } return $false } function Get-RecursivePrerequisiteGateSites { param( [Parameter(Mandatory)][string]$MethodKey, [Parameter(Mandatory)][hashtable]$MethodMap, [Parameter(Mandatory)][hashtable]$GateMap, [Parameter(Mandatory)][hashtable]$CallMap, [Parameter(Mandatory)][hashtable]$Visited ) if ($Visited.ContainsKey($MethodKey)) { return @() } $Visited[$MethodKey] = $true $sites = [Collections.Generic.List[object]]::new() foreach ($calledName in @($CallMap[$MethodKey])) { foreach ($candidateKey in @($MethodMap.Keys | Where-Object { $_.EndsWith("::$calledName", [StringComparison]::Ordinal) })) { foreach ($gate in @($GateMap[$candidateKey] | Where-Object { $null -ne $_.GateKind })) { $sites.Add([ordered]@{ Helper = $candidateKey Line = $gate.Line Condition = $gate.Condition GateKind = $gate.GateKind }) } foreach ($nestedSite in @(Get-RecursivePrerequisiteGateSites ` $candidateKey $MethodMap $GateMap $CallMap $Visited)) { $sites.Add($nestedSite) } } } return @($sites | Sort-Object Helper, Line, Condition -Unique) } $trackedFiles = @(& git -C $repoRoot ls-files tests | Where-Object { $_.EndsWith('.cs', [StringComparison]::OrdinalIgnoreCase) }) if ($LASTEXITCODE -ne 0) { throw 'git ls-files failed while enumerating test sources.' } $records = [Collections.Generic.List[object]]::new() $parsedFileCount = 0 foreach ($relativePath in $trackedFiles) { $absolutePath = Join-Path $repoRoot $relativePath if (-not (Test-Path -LiteralPath $absolutePath -PathType Leaf)) { continue } $source = [IO.File]::ReadAllText($absolutePath) $tree = [Microsoft.CodeAnalysis.CSharp.CSharpSyntaxTree]::ParseText($source) $root = $tree.GetRoot() $parsedFileCount++ $allMethods = @($root.DescendantNodes() | Where-Object { $_.GetType().Name -eq 'MethodDeclarationSyntax' }) $methodMap = @{} $failureMap = @{} $outputMap = @{} $gateMap = @{} $callMap = @{} foreach ($method in $allMethods) { $className = Get-ContainingClassName $method $key = "$className::$($method.Identifier.Text)" $methodMap[$key] = $method $failureMap[$key] = Test-DirectFailureSignal $method $outputMap[$key] = Test-DirectOutputSignal $method $gateMap[$key] = @(Get-EmptyReturnSites $tree $method) $callMap[$key] = @($method.DescendantNodes() | Where-Object { $_.GetType().Name -eq 'InvocationExpressionSyntax' } | ForEach-Object { Get-InvocationName $_ } | Sort-Object -Unique) } foreach ($method in $allMethods) { $methodAttributes = @($method.AttributeLists | ForEach-Object { $_.Attributes }) $attributeNames = @($methodAttributes | ForEach-Object { Get-ShortAttributeName $_ }) $testAttributes = @($attributeNames | Where-Object { $_ -in @('Fact', 'Theory', 'AvaloniaFact', 'InstalledDatFact') }) if ($testAttributes.Count -eq 0) { continue } $className = Get-ContainingClassName $method $key = "$className::$($method.Identifier.Text)" $hasFailureSignal = Test-RecursiveSignal ` $key $methodMap $failureMap $callMap @{} $hasOutputSignal = Test-RecursiveSignal ` $key $methodMap $outputMap $callMap @{} $emptyReturns = @($gateMap[$key]) $helperPrerequisiteReturns = @(Get-RecursivePrerequisiteGateSites ` $key $methodMap $gateMap $callMap @{}) $traits = [Collections.Generic.List[string]]::new() $staticSkip = $null $typeNode = Get-ContainingTypeNode $method $traitAttributes = @($methodAttributes) if ($null -ne $typeNode) { $traitAttributes += @($typeNode.AttributeLists | ForEach-Object { $_.Attributes }) } foreach ($attribute in $traitAttributes) { $attributeName = Get-ShortAttributeName $attribute $arguments = if ($null -eq $attribute.ArgumentList) { '' } else { $attribute.ArgumentList.ToString() } if ($attributeName -eq 'Trait') { $traits.Add($arguments.Trim('(', ')').Replace('"', '')) } } foreach ($attribute in $methodAttributes) { $arguments = if ($null -eq $attribute.ArgumentList) { '' } else { $attribute.ArgumentList.ToString() } if ($arguments -match '(^|[,\s])Skip\s*=') { $staticSkip = $arguments } } $bodyText = $method.ToString() $environmentVariables = @([regex]::Matches( $bodyText, 'GetEnvironmentVariable\s*\(\s*"([A-Za-z0-9_]+)"') | ForEach-Object { $_.Groups[1].Value } | Sort-Object -Unique) $records.Add([ordered]@{ Path = $relativePath.Replace('\', '/') Line = Get-NodeLine $tree $method Class = $className Method = $method.Identifier.Text Attributes = $testAttributes Traits = @($traits) StaticSkip = $staticSkip EmptyReturns = @($emptyReturns) HelperPrerequisiteReturns = @($helperPrerequisiteReturns) PrerequisiteReturnCandidate = @($emptyReturns | Where-Object { $null -ne $_.GateKind }).Count -gt 0 HelperPrerequisiteReturnCandidate = $helperPrerequisiteReturns.Count -gt 0 HasFailureSignal = $hasFailureSignal HasOutputSignal = $hasOutputSignal OutputOnlyCandidate = $hasOutputSignal -and -not $hasFailureSignal EnvironmentVariables = $environmentVariables HasThreadSleep = $bodyText -match '\bThread\.Sleep\s*\(' HasTaskDelay = $bodyText -match '\bTask\.Delay\s*\(' ReadsSourceText = $bodyText -match '(ReadAllText|ReadAllLines)\s*\(' -and $bodyText -match '\.cs' }) } } $orderedRecords = @($records | Sort-Object Path, Line, Method) $workingTreeStatus = @(& git -C $repoRoot status --short --untracked-files=no) $summary = [ordered]@{ GeneratedUtc = [DateTime]::UtcNow.ToString('O') RepositoryCommit = (& git -C $repoRoot rev-parse HEAD).Trim() WorkingTreeDirty = $workingTreeStatus.Count -gt 0 DotnetSdkVersion = $sdkVersion RoslynAssemblyVersion = $roslynVersion TrackedCSharpFiles = $trackedFiles.Count ParsedCSharpFiles = $parsedFileCount AttributedTestMethods = $orderedRecords.Count StaticSkipMethods = @($orderedRecords | Where-Object { $null -ne $_.StaticSkip }).Count EmptyReturnMethods = @($orderedRecords | Where-Object { $_.EmptyReturns.Count -gt 0 }).Count EmptyReturnSites = @($orderedRecords | ForEach-Object { $_.EmptyReturns }).Count PrerequisiteReturnCandidates = @($orderedRecords | Where-Object { $_.PrerequisiteReturnCandidate }).Count HelperPrerequisiteReturnCandidates = @($orderedRecords | Where-Object { $_.HelperPrerequisiteReturnCandidate }).Count HelperPrerequisiteReturnSites = @($orderedRecords | ForEach-Object { $_.HelperPrerequisiteReturns }).Count AnyPrerequisiteReturnCandidates = @($orderedRecords | Where-Object { $_.PrerequisiteReturnCandidate -or $_.HelperPrerequisiteReturnCandidate }).Count OutputOnlyCandidates = @($orderedRecords | Where-Object { $_.OutputOnlyCandidate }).Count DiagnosticMethods = @($orderedRecords | Where-Object { $_.Traits -contains 'Purpose, Diagnostic' }).Count KnownFailureMethods = @($orderedRecords | Where-Object { $_.Traits -contains 'Status, KnownFailure' }).Count LaneMethods = [ordered]@{ InstalledDat = @($orderedRecords | Where-Object { $_.Traits -contains 'Lane, InstalledDat' }).Count PreparedPackage = @($orderedRecords | Where-Object { $_.Traits -contains 'Lane, PreparedPackage' }).Count Live = @($orderedRecords | Where-Object { $_.Traits -contains 'Lane, Live' }).Count Manual = @($orderedRecords | Where-Object { $_.Traits -contains 'Lane, Manual' }).Count Windows = @($orderedRecords | Where-Object { $_.Traits -contains 'Lane, Windows' }).Count Linux = @($orderedRecords | Where-Object { $_.Traits -contains 'Lane, Linux' }).Count SystemFont = @($orderedRecords | Where-Object { $_.Traits -contains 'Lane, SystemFont' }).Count } ManualTaskMethods = [ordered]@{ FixtureGeneration = @($orderedRecords | Where-Object { $_.Traits -contains 'ManualTask, FixtureGeneration' }).Count LiveMountProbe = @($orderedRecords | Where-Object { $_.Traits -contains 'ManualTask, LiveMountProbe' }).Count PowerbarProbe = @($orderedRecords | Where-Object { $_.Traits -contains 'ManualTask, PowerbarProbe' }).Count } ThreadSleepMethods = @($orderedRecords | Where-Object { $_.HasThreadSleep }).Count TaskDelayMethods = @($orderedRecords | Where-Object { $_.HasTaskDelay }).Count DirectEnvironmentVariableMethods = @($orderedRecords | Where-Object { $_.EnvironmentVariables.Count -gt 0 }).Count SourceTextReadMethods = @($orderedRecords | Where-Object { $_.ReadsSourceText }).Count } $report = [ordered]@{ Summary = $summary Tests = $orderedRecords } $outputDirectory = Split-Path -Parent $resolvedOutput [IO.Directory]::CreateDirectory($outputDirectory) | Out-Null $report | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $resolvedOutput -Encoding utf8 $summary | Format-List Write-Output "Inventory: $resolvedOutput"