루트 밖 프로젝트/DLL 의존성 분석 지원
문제: 선택 폴더의 하위가 아닌 다른 폴더에 있는 ProjectReference/DLL 이 분석되지 않음 (예: BoxZainer → ..\..\Common\*, ..\..\DLL\*). - follow: 루트 밖 ProjectReference 를 전이적으로 따라가 노드로 포함 - externaldll: HintPath 로 참조하는 외부 DLL 을 (외부 DLL) 싱크 노드로 포함 - 그룹/노드명 기준을 포함된 파일들의 공통 상위 폴더(scanBase)로 자동 조정 - GUI 체크박스(루트 밖 따라가기 / 외부 DLL 포함) 및 ini(follow, externaldll) 추가 검증: BoxZainer → 189 노드(프로젝트 117 + 외부DLL 72) · 819 간선 · 순환 0 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
+98
-15
@@ -17,6 +17,8 @@ public sealed class DsmConfig
|
||||
public bool InternalAuto = true; // PackageReference↔프로젝트명 자동 매칭
|
||||
public Dictionary<string, string> ManualMap = new(); // 수동 pkgid→노드명(자동에 추가/보정)
|
||||
public string GroupMode = "auto"; // auto | topfolder | prefix | none
|
||||
public bool FollowExternal = true; // 루트 밖 ProjectReference 를 따라가 포함(전이적)
|
||||
public bool ExternalDlls = true; // Reference HintPath 로 참조하는 외부 DLL 을 노드로 포함
|
||||
|
||||
public static readonly string[] DefaultExcludePaths =
|
||||
{
|
||||
@@ -51,6 +53,8 @@ public sealed class DsmConfig
|
||||
case "samplemarker": samp.Add(v.ToLowerInvariant()); break;
|
||||
case "group": c.GroupMode = v.ToLowerInvariant(); break;
|
||||
case "internal": c.InternalAuto = !v.Equals("none", StringComparison.OrdinalIgnoreCase); break;
|
||||
case "follow": c.FollowExternal = !v.Equals("false", StringComparison.OrdinalIgnoreCase); break;
|
||||
case "externaldll": c.ExternalDlls = !v.Equals("false", StringComparison.OrdinalIgnoreCase); break;
|
||||
case "map":
|
||||
var i = v.IndexOf('=');
|
||||
if (i > 0) c.ManualMap[v[..i].Trim().ToLowerInvariant()] = v[(i + 1)..].Trim();
|
||||
@@ -95,22 +99,68 @@ public static class DsmEngine
|
||||
return s.Length == 0 ? "기타" : (s.Length > 10 ? s[..10] : s);
|
||||
}
|
||||
|
||||
// 포함된 모든 파일의 공통 상위 디렉터리(그룹/노드명 기준)
|
||||
private static string? CommonDir(List<string> files)
|
||||
{
|
||||
string[]? prefix = null;
|
||||
foreach (var f in files)
|
||||
{
|
||||
var parts = (Path.GetDirectoryName(f) ?? "").Split(Path.DirectorySeparatorChar);
|
||||
if (prefix == null) { prefix = parts; continue; }
|
||||
int n = Math.Min(prefix.Length, parts.Length), k = 0;
|
||||
while (k < n && string.Equals(prefix[k], parts[k], StringComparison.OrdinalIgnoreCase)) k++;
|
||||
prefix = prefix.Take(k).ToArray();
|
||||
}
|
||||
return prefix == null || prefix.Length == 0 ? null : string.Join(Path.DirectorySeparatorChar, prefix);
|
||||
}
|
||||
|
||||
// ---------- main ----------
|
||||
public static DsmResult Run(string root, string outDir, DsmConfig cfg, Action<string> log)
|
||||
{
|
||||
root = Path.GetFullPath(root);
|
||||
var excl = cfg.ExcludePaths;
|
||||
var eo = new EnumerationOptions { RecurseSubdirectories = true, IgnoreInaccessible = true };
|
||||
var files = new List<string>();
|
||||
bool Excluded(string p) { var l = p.ToLowerInvariant(); return excl.Any(x => l.Contains(x)); }
|
||||
var textCache = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
string? Read(string p)
|
||||
{
|
||||
if (textCache.TryGetValue(p, out var t)) return t;
|
||||
try { t = ReadText(p); } catch { t = null; }
|
||||
textCache[p] = t; return t;
|
||||
}
|
||||
var reProjRef = new Regex("ProjectReference\\s+Include\\s*=\\s*\"([^\"]+)\"", RegexOptions.IgnoreCase);
|
||||
|
||||
// 1) 선택 루트 하위 프로젝트(seed)
|
||||
var fileSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pat in new[] { "*.csproj", "*.vcxproj" })
|
||||
foreach (var f in Directory.EnumerateFiles(root, pat, eo))
|
||||
{
|
||||
var fl = f.ToLowerInvariant();
|
||||
if (!excl.Any(x => fl.Contains(x))) files.Add(Path.GetFullPath(f));
|
||||
var full = Path.GetFullPath(f);
|
||||
if (!Excluded(full)) fileSet.Add(full);
|
||||
}
|
||||
files = files.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
log($"프로젝트 파일 {files.Count}개 (제외 규칙 적용 후)");
|
||||
int seedCount = fileSet.Count;
|
||||
|
||||
// 2) 루트 밖 ProjectReference 를 전이적으로 따라가 포함
|
||||
if (cfg.FollowExternal)
|
||||
{
|
||||
var q = new Queue<string>(fileSet);
|
||||
while (q.Count > 0)
|
||||
{
|
||||
var f = q.Dequeue(); var txt = Read(f); if (txt == null) continue;
|
||||
var dir = Path.GetDirectoryName(f)!;
|
||||
foreach (Match m in reProjRef.Matches(txt))
|
||||
{
|
||||
string tgt; try { tgt = Path.GetFullPath(Path.Combine(dir, m.Groups[1].Value.Replace('\\', '/'))); } catch { continue; }
|
||||
if (fileSet.Contains(tgt) || Excluded(tgt)) continue;
|
||||
if (!tgt.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase) && !tgt.EndsWith(".vcxproj", StringComparison.OrdinalIgnoreCase)) continue;
|
||||
if (!File.Exists(tgt)) continue;
|
||||
fileSet.Add(tgt); q.Enqueue(tgt);
|
||||
}
|
||||
}
|
||||
}
|
||||
var files = fileSet.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
var scanBase = CommonDir(files) ?? root;
|
||||
log($"프로젝트 {files.Count}개 (루트내 {seedCount} + 외부참조 {files.Count - seedCount})");
|
||||
|
||||
// 노드명: 기본은 파일명. 동일 파일명 충돌 시 상대 디렉터리로 자동 구분.
|
||||
var baseName = files.ToDictionary(f => f, f => Path.GetFileNameWithoutExtension(f), StringComparer.OrdinalIgnoreCase);
|
||||
@@ -123,7 +173,7 @@ public static class DsmEngine
|
||||
var b = baseName[f];
|
||||
if (dupBases.Contains(b))
|
||||
{
|
||||
var relDir = Path.GetDirectoryName(Path.GetRelativePath(root, f)) ?? "";
|
||||
var relDir = Path.GetDirectoryName(Path.GetRelativePath(scanBase, f)) ?? "";
|
||||
path2node[f] = $"{b} ({relDir.Replace('\\', '/')})";
|
||||
}
|
||||
else path2node[f] = b;
|
||||
@@ -153,7 +203,7 @@ public static class DsmEngine
|
||||
// PackageId 선언 수집
|
||||
foreach (var f in files)
|
||||
{
|
||||
string txt; try { txt = ReadText(f); } catch { continue; }
|
||||
var txt = Read(f); if (txt == null) continue;
|
||||
var pm = rePkgId.Match(txt);
|
||||
if (pm.Success)
|
||||
{
|
||||
@@ -173,11 +223,26 @@ public static class DsmEngine
|
||||
// 정규화 매칭이 다중이면 모호 → 링크 생략(오탐 방지)
|
||||
}
|
||||
|
||||
// 외부 DLL(HintPath) 싱크 노드
|
||||
var extDll = new Dictionary<string, string>();
|
||||
var extDllSet = new HashSet<string>();
|
||||
string RegisterExtDll(string name)
|
||||
{
|
||||
var key = name.ToLowerInvariant();
|
||||
if (extDll.TryGetValue(key, out var lab)) return lab;
|
||||
lab = name + " (외부 DLL)";
|
||||
extDll[key] = lab; extDllSet.Add(lab);
|
||||
edges[lab] = new HashSet<string>(); nodeBase[lab] = name;
|
||||
return lab;
|
||||
}
|
||||
var reRefBlock = new Regex("<Reference\\s+Include\\s*=\\s*\"([^\"]+)\"[^>]*>(.*?)</Reference>", RegexOptions.IgnoreCase | RegexOptions.Singleline);
|
||||
var reHint = new Regex("<HintPath>\\s*([^<]+?)\\s*</HintPath>", RegexOptions.IgnoreCase);
|
||||
|
||||
foreach (var f in files)
|
||||
{
|
||||
var src = path2node[f];
|
||||
var dir = Path.GetDirectoryName(f)!;
|
||||
string txt; try { txt = ReadText(f); } catch { continue; }
|
||||
var txt = Read(f); if (txt == null) continue;
|
||||
|
||||
foreach (Match m in reProj.Matches(txt))
|
||||
{
|
||||
@@ -190,19 +255,37 @@ public static class DsmEngine
|
||||
if (byExact.TryGetValue(inc, out var ex)) { foreach (var t in ex) if (t != src) edges[src].Add(t); }
|
||||
else if (byNorm.TryGetValue(Norm(inc), out var nm) && nm.Count == 1 && nm[0] != src) edges[src].Add(nm[0]);
|
||||
}
|
||||
if (cfg.ExternalDlls)
|
||||
foreach (Match m in reRefBlock.Matches(txt))
|
||||
{
|
||||
var inc = m.Groups[1].Value.Split(',')[0].Trim();
|
||||
bool internalMatch = byExact.ContainsKey(inc) || (byNorm.TryGetValue(Norm(inc), out var nm2) && nm2.Count == 1);
|
||||
if (internalMatch) continue;
|
||||
var hint = reHint.Match(m.Groups[2].Value);
|
||||
if (!hint.Success) continue;
|
||||
string hp; try { hp = Path.GetFullPath(Path.Combine(dir, hint.Groups[1].Value.Replace('\\', '/'))); } catch { continue; }
|
||||
if (Excluded(hp) || !hp.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) continue;
|
||||
var en = RegisterExtDll(inc);
|
||||
if (en != src) edges[src].Add(en);
|
||||
}
|
||||
foreach (Match m in rePkg.Matches(txt)) LinkPackage(src, m.Groups[1].Value);
|
||||
}
|
||||
|
||||
// 노드→경로 역인덱스
|
||||
var node2path = new Dictionary<string, string>();
|
||||
foreach (var kv in path2node) node2path[kv.Value] = kv.Key;
|
||||
|
||||
// 샘플 제외
|
||||
var nodes = new HashSet<string>(path2node.Values);
|
||||
nodes.UnionWith(extDllSet);
|
||||
if (cfg.ExcludeSamples)
|
||||
{
|
||||
var markers = cfg.SampleMarkers;
|
||||
bool IsSample(string node)
|
||||
{
|
||||
var f = path2node.First(kv => kv.Value == node).Key.ToLowerInvariant();
|
||||
var nl = node.ToLowerInvariant();
|
||||
return markers.Any(mk => f.Contains(mk) || nl.Contains(mk));
|
||||
if (!node2path.TryGetValue(node, out var f)) return false; // 경로 없는 노드(외부 DLL)는 유지
|
||||
var fl = f.ToLowerInvariant(); var nl = node.ToLowerInvariant();
|
||||
return markers.Any(mk => fl.Contains(mk) || nl.Contains(mk));
|
||||
}
|
||||
nodes = new HashSet<string>(nodes.Where(n => !IsSample(n)));
|
||||
}
|
||||
@@ -232,8 +315,8 @@ public static class DsmEngine
|
||||
// 그룹핑
|
||||
string TopFolder(string node)
|
||||
{
|
||||
var f = path2node.First(kv => kv.Value == node).Key;
|
||||
var rel = Path.GetRelativePath(root, f).Replace('/', '\\').Split('\\');
|
||||
if (!node2path.TryGetValue(node, out var f)) return "(외부 DLL)";
|
||||
var rel = Path.GetRelativePath(scanBase, f).Replace('/', '\\').Split('\\');
|
||||
return rel.Length > 1 ? rel[0] : "(root)";
|
||||
}
|
||||
var mode = cfg.GroupMode;
|
||||
@@ -249,7 +332,7 @@ public static class DsmEngine
|
||||
"prefix" => n => NamePrefix(nodeBase[n]),
|
||||
_ => TopFolder,
|
||||
};
|
||||
var group = order.ToDictionary(n => n, groupOf);
|
||||
var group = order.ToDictionary(n => n, n => extDllSet.Contains(n) ? "(외부 DLL)" : groupOf(n));
|
||||
var gnames = group.Values.Distinct().OrderBy(x => x, StringComparer.Ordinal).ToList();
|
||||
string[] pal = { "#2ea043", "#1f6feb", "#8957e5", "#d29922", "#0969da", "#e36209", "#1a7f37", "#cf222e", "#6e7781", "#bf8700", "#a371f7", "#bc4c00", "#3fb950", "#db61a2", "#c9510c", "#0550ae" };
|
||||
var gcol = new Dictionary<string, string>();
|
||||
|
||||
@@ -25,19 +25,21 @@
|
||||
</Grid>
|
||||
|
||||
<!-- Options -->
|
||||
<StackPanel Grid.Row="3" Orientation="Horizontal" Margin="90,0,0,4" Spacing="18">
|
||||
<CheckBox x:Name="SamplesBox" IsChecked="True" Content="Sample/Test/Example 제외"/>
|
||||
<CheckBox x:Name="AutoInternalBox" IsChecked="True" Content="내부 NuGet 자동 감지"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<WrapPanel Grid.Row="3" Margin="90,0,0,4">
|
||||
<CheckBox x:Name="SamplesBox" IsChecked="True" Content="Sample/Test 제외" Margin="0,0,16,4"/>
|
||||
<CheckBox x:Name="AutoInternalBox" IsChecked="True" Content="내부 NuGet 자동감지" Margin="0,0,16,4"/>
|
||||
<CheckBox x:Name="FollowBox" IsChecked="True" Content="루트 밖 프로젝트 따라가기" Margin="0,0,16,4"/>
|
||||
<CheckBox x:Name="ExtDllBox" IsChecked="True" Content="외부 DLL 포함" Margin="0,0,16,4"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,0,0,4">
|
||||
<TextBlock Text="그룹:" VerticalAlignment="Center"/>
|
||||
<ComboBox x:Name="GroupCombo" Width="130" SelectedIndex="0">
|
||||
<ComboBox x:Name="GroupCombo" Width="120" SelectedIndex="0">
|
||||
<ComboBoxItem>auto</ComboBoxItem>
|
||||
<ComboBoxItem>topfolder</ComboBoxItem>
|
||||
<ComboBoxItem>prefix</ComboBoxItem>
|
||||
<ComboBoxItem>none</ComboBoxItem>
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</WrapPanel>
|
||||
|
||||
<Expander Grid.Row="4" Header="고급 설정 (제외 경로 · 수동 패키지 매핑)" Margin="0,4,0,8">
|
||||
<StackPanel Spacing="6" Margin="4">
|
||||
|
||||
@@ -71,6 +71,8 @@ public partial class MainWindow : Window
|
||||
{
|
||||
ExcludeSamples = SamplesBox.IsChecked == true,
|
||||
InternalAuto = AutoInternalBox.IsChecked == true,
|
||||
FollowExternal = FollowBox.IsChecked == true,
|
||||
ExternalDlls = ExtDllBox.IsChecked == true,
|
||||
GroupMode = (GroupCombo.SelectedItem as ContentControl)?.Content?.ToString() ?? "auto",
|
||||
ExcludePaths = SplitLines(ExcludeBox.Text).Select(x => x.ToLowerInvariant()).ToList(),
|
||||
ManualMap = ParseMap(SplitLines(ComponentBox.Text)),
|
||||
|
||||
@@ -17,9 +17,14 @@
|
||||
노드는 위상 정렬(공급자 먼저)되며, 순환이 없으면 하삼각 행렬이 된다(상삼각 위반 0 = 비순환 = 빌드 순서).
|
||||
동일 파일명이 여러 개면 상대경로로 자동 구분한다.
|
||||
|
||||
**루트 밖 참조도 분석**(중요): 선택한 폴더 밖에 있는 프로젝트/DLL도 포함한다.
|
||||
- `follow`(기본 ON): 루트 밖 **ProjectReference 를 전이적으로 따라가** 그 프로젝트(및 그 의존)까지 노드로 포함.
|
||||
- `externaldll`(기본 ON): `HintPath` 로 참조하는 외부 **DLL** 을 `(외부 DLL)` 노드로 포함.
|
||||
- 그룹/노드명 기준은 포함된 모든 파일의 **공통 상위 폴더**로 자동 설정(예: 여러 폴더에 걸치면 `Common`/`Product`/`(외부 DLL)` 등으로 분리).
|
||||
|
||||
## 사용법 (GUI)
|
||||
1. `DsmTool.exe` 실행 → **대상 폴더** 선택(필요 시 **출력 폴더**).
|
||||
2. 옵션: *Sample 제외*, *내부 NuGet 자동 감지*, **그룹 모드**(auto/topfolder/prefix/none).
|
||||
2. 옵션: *Sample 제외* · *내부 NuGet 자동감지* · *루트 밖 프로젝트 따라가기* · *외부 DLL 포함* · **그룹 모드**.
|
||||
*고급 설정* 에서 제외 경로·수동 패키지 매핑 조정.
|
||||
3. **생성** → `{분석폴더명}_DependencyMetrix.html`, `{분석폴더명}_DependencyMetrix.csv`
|
||||
(예: `D:\ZOO` 분석 시 `ZOO_DependencyMetrix.html`).
|
||||
|
||||
@@ -28,9 +28,17 @@ internal = auto
|
||||
# map = TeighaCodePack_22.12_16 = TeighaCodePack (Component/TeighaCodePack/Ver_22.12_16)
|
||||
# map = MyInternalPkg = MyProject
|
||||
|
||||
# --- 루트 밖 참조 처리 ----------------------------------------------
|
||||
# follow : 루트 밖 ProjectReference 를 전이적으로 따라가 포함
|
||||
# (다른 폴더에 있는 프로젝트도 분석). 끄려면 follow = false
|
||||
# externaldll : Reference HintPath 로 참조하는 외부 DLL 을 (외부 DLL) 노드로 포함
|
||||
# 끄려면 externaldll = false
|
||||
follow = true
|
||||
externaldll = true
|
||||
|
||||
# --- 그룹(색상) 모드 -------------------------------------------------
|
||||
# auto : 최상위폴더로 묶되, 평면 배치면 이름 접두어로 자동 전환
|
||||
# topfolder : 루트 바로 아래 폴더
|
||||
# topfolder : (포함된 파일들의 공통 상위폴더) 바로 아래 폴더
|
||||
# prefix : 프로젝트명 접두어(Eg, Editor ...)
|
||||
# none : 단일 그룹
|
||||
group = auto
|
||||
|
||||
Reference in New Issue
Block a user