Files
build_infra/0_cmake_library/Development_Quality_Infrastructure/CodeMetrics/Coupling.ps1.in
T
2026-07-30 18:09:03 +08:00

229 lines
9.3 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@"
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 + "/", [StringComparison]::OrdinalIgnoreCase) -or $normalized.Equals($root, [StringComparison]::OrdinalIgnoreCase)) {
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)) {
$files.Add($path)
}
}
}
$files = @($files | Sort-Object -Unique)
$fileSet = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
$lookup = [Collections.Generic.Dictionary[string, Collections.Generic.List[string]]]::new([StringComparer]::OrdinalIgnoreCase)
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, [StringComparison]::OrdinalIgnoreCase) })
if ($suffixMatches.Count -eq 1) {
return $suffixMatches[0]
}
return $null
}
$edges = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
$outgoing = [Collections.Generic.Dictionary[string, Collections.Generic.HashSet[string]]]::new([StringComparer]::OrdinalIgnoreCase)
$incoming = [Collections.Generic.Dictionary[string, Collections.Generic.HashSet[string]]]::new([StringComparer]::OrdinalIgnoreCase)
foreach ($file in $files) {
$outgoing[$file] = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
$incoming[$file] = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
}
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([StringComparer]::OrdinalIgnoreCase)
$modules = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
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
$component.Add((Relative-Display $member))
} while ($member -ne $Node)
if ($component.Count -gt 1) {
$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()
$dotLines.Add("digraph coupling {")
$dotLines.Add(' graph [rankdir=LR, bgcolor="transparent"];')
$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('"', '\"')
$dotLines.Add(" `"$left`" -> `"$right`";")
}
$dotLines.Add("}")
[IO.File]::WriteAllLines($dotPath, $dotLines, [Text.UTF8Encoding]::new($false))
if (Test-Path -LiteralPath $dotExecutable) {
& $dotExecutable "-Tsvg" $dotPath "-o" (Join-Path $outputDir "module_coupling.svg")
}
$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"