686 lines
26 KiB
PowerShell
686 lines
26 KiB
PowerShell
<#
|
|
.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,
|
|
high-confidence constant-truth assertions, syntactic self-comparison
|
|
candidates, 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 '<global>'
|
|
}
|
|
|
|
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 = '<unconditional>'
|
|
$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 '(^|\.)File\.(WriteAllText|WriteAllLines|WriteAllBytes|AppendAllText|AppendAllLines)$' -or
|
|
$expression -match '(^|\.)(WriteDiagnostic|Dump|DumpSteps|Print)(\.|$)') {
|
|
return $true
|
|
}
|
|
}
|
|
return $false
|
|
}
|
|
|
|
function Get-AssertionAuditSites {
|
|
param(
|
|
[Parameter(Mandatory)]$Tree,
|
|
[Parameter(Mandatory)]$Method
|
|
)
|
|
|
|
$sites = [Collections.Generic.List[object]]::new()
|
|
foreach ($invocation in @($Method.DescendantNodes() | Where-Object {
|
|
$_.GetType().Name -eq 'InvocationExpressionSyntax'
|
|
})) {
|
|
$expression = $invocation.Expression.ToString()
|
|
$arguments = @($invocation.ArgumentList.Arguments)
|
|
if ($arguments.Count -eq 0) {
|
|
continue
|
|
}
|
|
|
|
$first = $arguments[0].Expression.ToString()
|
|
$constantTruthKind = if ($expression -match '(^|\.)Assert\.True$' -and
|
|
$first -ceq 'true') {
|
|
'Assert.True(true)'
|
|
} elseif ($expression -match '(^|\.)Assert\.False$' -and
|
|
$first -ceq 'false') {
|
|
'Assert.False(false)'
|
|
} else {
|
|
$null
|
|
}
|
|
if ($null -ne $constantTruthKind) {
|
|
$sites.Add([ordered]@{
|
|
Line = Get-NodeLine $Tree $invocation
|
|
Category = 'ConstantTruth'
|
|
Kind = $constantTruthKind
|
|
Invocation = [regex]::Replace($invocation.ToString(), '\s+', ' ').Trim()
|
|
})
|
|
}
|
|
|
|
if ($arguments.Count -lt 2 -or
|
|
$expression -notmatch '(^|\.)Assert\.(Equal|StrictEqual|Same|NotEqual|NotSame)$') {
|
|
continue
|
|
}
|
|
|
|
$second = $arguments[1].Expression.ToString()
|
|
if ($first -cne $second) {
|
|
continue
|
|
}
|
|
$sites.Add([ordered]@{
|
|
Line = Get-NodeLine $Tree $invocation
|
|
Category = 'SelfComparison'
|
|
Kind = "Assert.$($expression.Split('.')[-1])"
|
|
Invocation = [regex]::Replace($invocation.ToString(), '\s+', ' ').Trim()
|
|
})
|
|
}
|
|
return @($sites)
|
|
}
|
|
|
|
function Get-WaitSites {
|
|
param(
|
|
[Parameter(Mandatory)]$Tree,
|
|
[Parameter(Mandatory)]$Method
|
|
)
|
|
|
|
$sites = [Collections.Generic.List[object]]::new()
|
|
foreach ($invocation in @($Method.DescendantNodes() | Where-Object {
|
|
$_.GetType().Name -eq 'InvocationExpressionSyntax'
|
|
})) {
|
|
$expression = $invocation.Expression.ToString()
|
|
$kind = if ($expression -match '(^|\.)Thread\.Sleep$') {
|
|
'ThreadSleep'
|
|
} elseif ($expression -match '(^|\.)Task\.Delay$') {
|
|
'TaskDelay'
|
|
} else {
|
|
$null
|
|
}
|
|
if ($null -eq $kind) {
|
|
continue
|
|
}
|
|
|
|
$text = [regex]::Replace($invocation.ToString(), '\s+', ' ').Trim()
|
|
$sites.Add([ordered]@{
|
|
Line = Get-NodeLine $Tree $invocation
|
|
Kind = $kind
|
|
Invocation = $text
|
|
IsCancellableInfiniteDelay = $kind -eq 'TaskDelay' -and
|
|
$text -match 'Timeout\.InfiniteTimeSpan' -and
|
|
$invocation.ArgumentList.Arguments.Count -ge 2
|
|
})
|
|
}
|
|
return @($sites)
|
|
}
|
|
|
|
function Get-BodyFingerprint {
|
|
param([Parameter(Mandatory)]$Method)
|
|
|
|
$bodyNode = if ($null -ne $Method.Body) {
|
|
$Method.Body
|
|
} else {
|
|
$Method.ExpressionBody
|
|
}
|
|
if ($null -eq $bodyNode) {
|
|
return $null
|
|
}
|
|
|
|
$bodyText = $bodyNode.ToString().Replace("`r`n", "`n")
|
|
$bytes = [Text.Encoding]::UTF8.GetBytes($bodyText)
|
|
return [Convert]::ToHexString(
|
|
[Security.Cryptography.SHA256]::HashData($bytes))
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
$inlineDataRows = @($methodAttributes | Where-Object {
|
|
(Get-ShortAttributeName $_) -eq 'InlineData'
|
|
} | ForEach-Object {
|
|
if ($null -eq $_.ArgumentList) { '()' } else { $_.ArgumentList.ToString() }
|
|
})
|
|
$duplicateInlineDataRows = @($inlineDataRows |
|
|
Group-Object -CaseSensitive |
|
|
Where-Object { $_.Count -gt 1 } |
|
|
ForEach-Object { $_.Name })
|
|
|
|
$bodyText = $method.ToString()
|
|
$waitSites = @(Get-WaitSites $tree $method)
|
|
$assertionAuditSites = @(Get-AssertionAuditSites $tree $method)
|
|
$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
|
|
BodyFingerprint = Get-BodyFingerprint $method
|
|
InlineDataRows = $inlineDataRows
|
|
DuplicateInlineDataRows = $duplicateInlineDataRows
|
|
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
|
|
ConstantTruthAssertions = @($assertionAuditSites | Where-Object {
|
|
$_.Category -eq 'ConstantTruth'
|
|
})
|
|
SelfComparisonAssertions = @($assertionAuditSites | Where-Object {
|
|
$_.Category -eq 'SelfComparison'
|
|
})
|
|
EnvironmentVariables = $environmentVariables
|
|
WaitSites = $waitSites
|
|
HasThreadSleep = @($waitSites | Where-Object {
|
|
$_.Kind -eq 'ThreadSleep'
|
|
}).Count -gt 0
|
|
HasTaskDelay = @($waitSites | Where-Object {
|
|
$_.Kind -eq 'TaskDelay'
|
|
}).Count -gt 0
|
|
HasOnlyCancellableInfiniteDelay = $waitSites.Count -gt 0 -and
|
|
@($waitSites | Where-Object {
|
|
-not $_.IsCancellableInfiniteDelay
|
|
}).Count -eq 0
|
|
ReadsSourceText = $bodyText -match '(ReadAllText|ReadAllLines)\s*\(' -and
|
|
$bodyText -match '\.cs'
|
|
})
|
|
}
|
|
}
|
|
|
|
$orderedRecords = @($records | Sort-Object Path, Line, Method)
|
|
$duplicateBodyGroups = @($orderedRecords |
|
|
Where-Object { $null -ne $_.BodyFingerprint } |
|
|
Group-Object { $_.BodyFingerprint } |
|
|
Where-Object { $_.Count -gt 1 } |
|
|
ForEach-Object {
|
|
[ordered]@{
|
|
Fingerprint = $_.Name
|
|
Count = $_.Count
|
|
Tests = @($_.Group | ForEach-Object {
|
|
[ordered]@{
|
|
Path = $_.Path
|
|
Line = $_.Line
|
|
Class = $_.Class
|
|
Method = $_.Method
|
|
}
|
|
})
|
|
}
|
|
})
|
|
$duplicateDataRowsAcrossEquivalentBodies = [Collections.Generic.List[object]]::new()
|
|
foreach ($bodyGroup in $duplicateBodyGroups) {
|
|
$occurrences = @($bodyGroup.Tests | ForEach-Object {
|
|
$test = $_
|
|
$record = $orderedRecords | Where-Object {
|
|
$_.Path -eq $test.Path -and
|
|
$_.Line -eq $test.Line -and
|
|
$_.Class -eq $test.Class -and
|
|
$_.Method -eq $test.Method
|
|
} | Select-Object -First 1
|
|
foreach ($row in @($record.InlineDataRows)) {
|
|
[ordered]@{
|
|
Row = $row
|
|
Path = $test.Path
|
|
Line = $test.Line
|
|
Class = $test.Class
|
|
Method = $test.Method
|
|
}
|
|
}
|
|
})
|
|
foreach ($rowGroup in @($occurrences |
|
|
Group-Object { $_.Row } -CaseSensitive |
|
|
Where-Object { $_.Count -gt 1 })) {
|
|
$duplicateDataRowsAcrossEquivalentBodies.Add([ordered]@{
|
|
BodyFingerprint = $bodyGroup.Fingerprint
|
|
Row = $rowGroup.Name
|
|
Occurrences = @($rowGroup.Group)
|
|
})
|
|
}
|
|
}
|
|
$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
|
|
DuplicateInlineDataMethods = @($orderedRecords | Where-Object {
|
|
$_.DuplicateInlineDataRows.Count -gt 0
|
|
}).Count
|
|
ExactBodyDuplicateGroups = $duplicateBodyGroups.Count
|
|
MethodsInExactBodyDuplicateGroups = @($duplicateBodyGroups |
|
|
ForEach-Object { $_.Tests }).Count
|
|
DuplicateDataRowsAcrossEquivalentBodies =
|
|
$duplicateDataRowsAcrossEquivalentBodies.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
|
|
ConstantTruthAssertionMethods = @($orderedRecords | Where-Object {
|
|
$_.ConstantTruthAssertions.Count -gt 0
|
|
}).Count
|
|
ConstantTruthAssertionSites = @($orderedRecords |
|
|
ForEach-Object { $_.ConstantTruthAssertions }).Count
|
|
SelfComparisonAssertionMethods = @($orderedRecords | Where-Object {
|
|
$_.SelfComparisonAssertions.Count -gt 0
|
|
}).Count
|
|
SelfComparisonAssertionSites = @($orderedRecords |
|
|
ForEach-Object { $_.SelfComparisonAssertions }).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
|
|
CancellableInfiniteDelayOnlyMethods = @($orderedRecords | Where-Object {
|
|
$_.HasOnlyCancellableInfiniteDelay
|
|
}).Count
|
|
FixedWallClockWaitMethods = @($orderedRecords | Where-Object {
|
|
$_.WaitSites.Count -gt 0 -and -not $_.HasOnlyCancellableInfiniteDelay
|
|
}).Count
|
|
DirectEnvironmentVariableMethods = @($orderedRecords | Where-Object {
|
|
$_.EnvironmentVariables.Count -gt 0
|
|
}).Count
|
|
SourceTextReadMethods = @($orderedRecords | Where-Object { $_.ReadsSourceText }).Count
|
|
}
|
|
|
|
$report = [ordered]@{
|
|
Summary = $summary
|
|
ExactBodyDuplicateGroups = $duplicateBodyGroups
|
|
DuplicateDataRowsAcrossEquivalentBodies =
|
|
@($duplicateDataRowsAcrossEquivalentBodies)
|
|
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"
|