235 lines
9.5 KiB
Plaintext
235 lines
9.5 KiB
Plaintext
$ErrorActionPreference = "Stop"
|
|
$inputDirs = @PSC_COUPLING_INPUT_DIRS@
|
|
$excludeDirs = @PSC_COUPLING_EXCLUDE_DIRS@
|
|
$outputDir = "@PSC_COUPLING_OUTPUT_DIR@"
|
|
$topN = [int]"@PSC_COUPLING_TOP_N@"
|
|
$dotExecutable = "@PSC_COUPLING_DOT@"
|
|
if (!(Test-Path -LiteralPath $dotExecutable)) {
|
|
throw "Graphviz dot executable was not found: $dotExecutable"
|
|
}
|
|
$pathComparison = if ($IsWindows) { [StringComparison]::OrdinalIgnoreCase } else { [StringComparison]::Ordinal }
|
|
$pathComparer = if ($IsWindows) { [StringComparer]::OrdinalIgnoreCase } else { [StringComparer]::Ordinal }
|
|
function Normalize-Path([string]$Path) {
|
|
return [IO.Path]::GetFullPath($Path).Replace("\", "/").TrimEnd("/")
|
|
}
|
|
function Test-InRoots([string]$Path, [string[]]$Roots) {
|
|
$normalized = Normalize-Path $Path
|
|
foreach ($root in $Roots) {
|
|
if ($normalized.StartsWith($root + "/", $pathComparison) -or $normalized.Equals($root, $pathComparison)) {
|
|
return $true
|
|
}
|
|
}
|
|
return $false
|
|
}
|
|
function Add-Lookup([Collections.Generic.Dictionary[string, Collections.Generic.List[string]]]$Lookup, [string]$Key, [string]$Value) {
|
|
$normalizedKey = $Key.Replace("\", "/").TrimStart("/")
|
|
if (!$Lookup.ContainsKey($normalizedKey)) {
|
|
$Lookup[$normalizedKey] = [Collections.Generic.List[string]]::new()
|
|
}
|
|
if (!$Lookup[$normalizedKey].Contains($Value)) {
|
|
$Lookup[$normalizedKey].Add($Value)
|
|
}
|
|
}
|
|
$roots = @($inputDirs | ForEach-Object { Normalize-Path $_ })
|
|
$excludes = @($excludeDirs | ForEach-Object { Normalize-Path $_ })
|
|
$extensions = @(".h", ".hh", ".hpp", ".hxx", ".c", ".cc", ".cpp", ".cxx")
|
|
$files = [Collections.Generic.List[string]]::new()
|
|
foreach ($root in $roots) {
|
|
Get-ChildItem -LiteralPath $root -Recurse -File | ForEach-Object {
|
|
$path = Normalize-Path $_.FullName
|
|
if ($_.Extension.ToLowerInvariant() -in $extensions -and !(Test-InRoots $path $excludes)) {
|
|
[void]$files.Add($path)
|
|
}
|
|
}
|
|
}
|
|
$files = @($files | Sort-Object -Unique)
|
|
$fileSet = [Collections.Generic.HashSet[string]]::new($pathComparer)
|
|
$lookup = [Collections.Generic.Dictionary[string, Collections.Generic.List[string]]]::new($pathComparer)
|
|
foreach ($file in $files) {
|
|
$fileSet.Add($file) | Out-Null
|
|
foreach ($root in $roots) {
|
|
if (Test-InRoots $file @($root)) {
|
|
Add-Lookup $lookup $file.Substring($root.Length).TrimStart("/") $file
|
|
}
|
|
}
|
|
}
|
|
function Resolve-Include([string]$Source, [string]$Include) {
|
|
$relativeCandidate = Normalize-Path (Join-Path (Split-Path $Source -Parent) $Include)
|
|
if ($fileSet.Contains($relativeCandidate)) {
|
|
return $relativeCandidate
|
|
}
|
|
$key = $Include.Replace("\", "/").TrimStart("/")
|
|
if ($lookup.ContainsKey($key) -and $lookup[$key].Count -eq 1) {
|
|
return $lookup[$key][0]
|
|
}
|
|
foreach ($root in $roots) {
|
|
$rootCandidate = Normalize-Path (Join-Path $root $Include)
|
|
if ($fileSet.Contains($rootCandidate)) {
|
|
return $rootCandidate
|
|
}
|
|
}
|
|
$suffixMatches = @($files | Where-Object { $_.EndsWith("/" + $key, $pathComparison) })
|
|
if ($suffixMatches.Count -eq 1) {
|
|
return $suffixMatches[0]
|
|
}
|
|
return $null
|
|
}
|
|
$edges = [Collections.Generic.HashSet[string]]::new($pathComparer)
|
|
$outgoing = [Collections.Generic.Dictionary[string, Collections.Generic.HashSet[string]]]::new($pathComparer)
|
|
$incoming = [Collections.Generic.Dictionary[string, Collections.Generic.HashSet[string]]]::new($pathComparer)
|
|
foreach ($file in $files) {
|
|
$outgoing[$file] = [Collections.Generic.HashSet[string]]::new($pathComparer)
|
|
$incoming[$file] = [Collections.Generic.HashSet[string]]::new($pathComparer)
|
|
}
|
|
foreach ($source in $files) {
|
|
foreach ($line in Get-Content -LiteralPath $source) {
|
|
if ($line -notmatch '^\s*#\s*include\s*["<](?<include>[^">]+)[">]') {
|
|
continue
|
|
}
|
|
$target = Resolve-Include $source $Matches.include
|
|
if (!$target -or $target -eq $source) {
|
|
continue
|
|
}
|
|
$edge = $source + "|" + $target
|
|
if ($edges.Add($edge)) {
|
|
$outgoing[$source].Add($target) | Out-Null
|
|
$incoming[$target].Add($source) | Out-Null
|
|
}
|
|
}
|
|
}
|
|
function Relative-Display([string]$Path) {
|
|
foreach ($root in $roots) {
|
|
if (Test-InRoots $Path @($root)) {
|
|
return (Split-Path $root -Leaf) + "/" + $Path.Substring($root.Length).TrimStart("/")
|
|
}
|
|
}
|
|
return $Path
|
|
}
|
|
function Module-Name([string]$Path) {
|
|
foreach ($root in $roots) {
|
|
if (Test-InRoots $Path @($root)) {
|
|
$relative = $Path.Substring($root.Length).TrimStart("/")
|
|
$parts = $relative.Split("/")
|
|
$rootName = Split-Path $root -Leaf
|
|
if ($parts.Count -gt 1) {
|
|
return $rootName + "/" + $parts[0]
|
|
}
|
|
return $rootName
|
|
}
|
|
}
|
|
return "<external>"
|
|
}
|
|
$fileStats = @($files | ForEach-Object {
|
|
$ca = $incoming[$_].Count
|
|
$ce = $outgoing[$_].Count
|
|
[pscustomobject]@{
|
|
File = Relative-Display $_
|
|
Afferent = $ca
|
|
Efferent = $ce
|
|
Coupling = $ca + $ce
|
|
Instability = if ($ca + $ce) { [Math]::Round($ce / ($ca + $ce), 4) } else { 0.0 }
|
|
}
|
|
})
|
|
$moduleEdges = [Collections.Generic.HashSet[string]]::new($pathComparer)
|
|
$modules = [Collections.Generic.HashSet[string]]::new($pathComparer)
|
|
foreach ($file in $files) {
|
|
$modules.Add((Module-Name $file)) | Out-Null
|
|
}
|
|
foreach ($edge in $edges) {
|
|
$parts = $edge.Split("|", 2)
|
|
$sourceModule = Module-Name $parts[0]
|
|
$targetModule = Module-Name $parts[1]
|
|
if ($sourceModule -ne $targetModule) {
|
|
$moduleEdges.Add($sourceModule + "|" + $targetModule) | Out-Null
|
|
}
|
|
}
|
|
$moduleStats = @($modules | ForEach-Object {
|
|
$module = $_
|
|
$ca = @($moduleEdges | Where-Object { $_.Split("|", 2)[1] -eq $module }).Count
|
|
$ce = @($moduleEdges | Where-Object { $_.Split("|", 2)[0] -eq $module }).Count
|
|
[pscustomobject]@{
|
|
Module = $module
|
|
Afferent = $ca
|
|
Efferent = $ce
|
|
Coupling = $ca + $ce
|
|
Instability = if ($ca + $ce) { [Math]::Round($ce / ($ca + $ce), 4) } else { 0.0 }
|
|
}
|
|
})
|
|
$script:index = 0
|
|
$script:indices = @{}
|
|
$script:lowLinks = @{}
|
|
$script:onStack = @{}
|
|
$script:stack = [Collections.Generic.Stack[string]]::new()
|
|
$script:components = [Collections.Generic.List[object]]::new()
|
|
function Visit-Node([string]$Node) {
|
|
$script:indices[$Node] = $script:index
|
|
$script:lowLinks[$Node] = $script:index
|
|
$script:index++
|
|
$script:stack.Push($Node)
|
|
$script:onStack[$Node] = $true
|
|
foreach ($next in $outgoing[$Node]) {
|
|
if (!$script:indices.ContainsKey($next)) {
|
|
Visit-Node $next
|
|
$script:lowLinks[$Node] = [Math]::Min($script:lowLinks[$Node], $script:lowLinks[$next])
|
|
}
|
|
elseif ($script:onStack[$next]) {
|
|
$script:lowLinks[$Node] = [Math]::Min($script:lowLinks[$Node], $script:indices[$next])
|
|
}
|
|
}
|
|
if ($script:lowLinks[$Node] -ne $script:indices[$Node]) {
|
|
return
|
|
}
|
|
$component = [Collections.Generic.List[string]]::new()
|
|
do {
|
|
$member = $script:stack.Pop()
|
|
$script:onStack[$member] = $false
|
|
[void]$component.Add((Relative-Display $member))
|
|
} while ($member -ne $Node)
|
|
if ($component.Count -gt 1) {
|
|
[void]$script:components.Add(@($component | Sort-Object))
|
|
}
|
|
}
|
|
foreach ($file in $files) {
|
|
if (!$script:indices.ContainsKey($file)) {
|
|
Visit-Node $file
|
|
}
|
|
}
|
|
$fileTop = @($fileStats | Sort-Object @{Expression = "Coupling"; Descending = $true}, @{Expression = "Efferent"; Descending = $true} | Select-Object -First $topN)
|
|
$moduleTop = @($moduleStats | Sort-Object @{Expression = "Coupling"; Descending = $true}, @{Expression = "Efferent"; Descending = $true} | Select-Object -First $topN)
|
|
New-Item -ItemType Directory -Force -Path $outputDir | Out-Null
|
|
$fileTop | Export-Csv -LiteralPath (Join-Path $outputDir "file_coupling.csv") -NoTypeInformation -Encoding UTF8
|
|
$moduleTop | Export-Csv -LiteralPath (Join-Path $outputDir "module_coupling.csv") -NoTypeInformation -Encoding UTF8
|
|
$edgeRecords = @($edges | ForEach-Object {
|
|
$parts = $_.Split("|", 2)
|
|
[pscustomobject]@{ Source = Relative-Display $parts[0]; Target = Relative-Display $parts[1] }
|
|
})
|
|
$edgeRecords | Export-Csv -LiteralPath (Join-Path $outputDir "include_edges.csv") -NoTypeInformation -Encoding UTF8
|
|
$dotPath = Join-Path $outputDir "module_coupling.dot"
|
|
$dotLines = [Collections.Generic.List[string]]::new()
|
|
[void]$dotLines.Add("digraph coupling {")
|
|
[void]$dotLines.Add(' graph [rankdir=LR, bgcolor="transparent"];')
|
|
[void]$dotLines.Add(' node [shape=box, style="rounded,filled", fillcolor="#e8eef7", color="#6682a3", fontname="Segoe UI"];')
|
|
foreach ($edge in $moduleEdges) {
|
|
$parts = $edge.Split("|", 2)
|
|
$left = $parts[0].Replace('"', '\"')
|
|
$right = $parts[1].Replace('"', '\"')
|
|
[void]$dotLines.Add(" `"$left`" -> `"$right`";")
|
|
}
|
|
[void]$dotLines.Add("}")
|
|
[IO.File]::WriteAllLines($dotPath, $dotLines, [Text.UTF8Encoding]::new($false))
|
|
& $dotExecutable "-Tsvg" $dotPath "-o" (Join-Path $outputDir "module_coupling.svg")
|
|
if ($LASTEXITCODE -ne 0) {
|
|
throw "Graphviz dot failed with exit code $LASTEXITCODE"
|
|
}
|
|
$summary = [ordered]@{
|
|
files = $files.Count
|
|
include_edges = $edges.Count
|
|
modules = $modules.Count
|
|
module_edges = $moduleEdges.Count
|
|
cycles = @($script:components)
|
|
file_top = $fileTop
|
|
module_top = $moduleTop
|
|
}
|
|
[IO.File]::WriteAllText((Join-Path $outputDir "summary.json"), ($summary | ConvertTo-Json -Depth 10), [Text.UTF8Encoding]::new($false))
|
|
Write-Output "Coupling report: $outputDir"
|