acdream/tools/audit-test-inventory.ps1

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"