루트 밖 프로젝트/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:
2026-07-08 11:38:30 +09:00
co-authored by Claude Opus 4.8
parent 50922d8973
commit d10470e1fd
6 changed files with 123 additions and 23 deletions
BIN
View File
Binary file not shown.
+98 -15
View File
@@ -17,6 +17,8 @@ public sealed class DsmConfig
public bool InternalAuto = true; // PackageReference↔프로젝트명 자동 매칭 public bool InternalAuto = true; // PackageReference↔프로젝트명 자동 매칭
public Dictionary<string, string> ManualMap = new(); // 수동 pkgid→노드명(자동에 추가/보정) public Dictionary<string, string> ManualMap = new(); // 수동 pkgid→노드명(자동에 추가/보정)
public string GroupMode = "auto"; // auto | topfolder | prefix | none 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 = public static readonly string[] DefaultExcludePaths =
{ {
@@ -51,6 +53,8 @@ public sealed class DsmConfig
case "samplemarker": samp.Add(v.ToLowerInvariant()); break; case "samplemarker": samp.Add(v.ToLowerInvariant()); break;
case "group": c.GroupMode = v.ToLowerInvariant(); break; case "group": c.GroupMode = v.ToLowerInvariant(); break;
case "internal": c.InternalAuto = !v.Equals("none", StringComparison.OrdinalIgnoreCase); 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": case "map":
var i = v.IndexOf('='); var i = v.IndexOf('=');
if (i > 0) c.ManualMap[v[..i].Trim().ToLowerInvariant()] = v[(i + 1)..].Trim(); 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); 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 ---------- // ---------- main ----------
public static DsmResult Run(string root, string outDir, DsmConfig cfg, Action<string> log) public static DsmResult Run(string root, string outDir, DsmConfig cfg, Action<string> log)
{ {
root = Path.GetFullPath(root); root = Path.GetFullPath(root);
var excl = cfg.ExcludePaths; var excl = cfg.ExcludePaths;
var eo = new EnumerationOptions { RecurseSubdirectories = true, IgnoreInaccessible = true }; 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 pat in new[] { "*.csproj", "*.vcxproj" })
foreach (var f in Directory.EnumerateFiles(root, pat, eo)) foreach (var f in Directory.EnumerateFiles(root, pat, eo))
{ {
var fl = f.ToLowerInvariant(); var full = Path.GetFullPath(f);
if (!excl.Any(x => fl.Contains(x))) files.Add(Path.GetFullPath(f)); if (!Excluded(full)) fileSet.Add(full);
} }
files = files.Distinct(StringComparer.OrdinalIgnoreCase) int seedCount = fileSet.Count;
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList();
log($"프로젝트 파일 {files.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); var baseName = files.ToDictionary(f => f, f => Path.GetFileNameWithoutExtension(f), StringComparer.OrdinalIgnoreCase);
@@ -123,7 +173,7 @@ public static class DsmEngine
var b = baseName[f]; var b = baseName[f];
if (dupBases.Contains(b)) 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('\\', '/')})"; path2node[f] = $"{b} ({relDir.Replace('\\', '/')})";
} }
else path2node[f] = b; else path2node[f] = b;
@@ -153,7 +203,7 @@ public static class DsmEngine
// PackageId 선언 수집 // PackageId 선언 수집
foreach (var f in files) 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); var pm = rePkgId.Match(txt);
if (pm.Success) 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) foreach (var f in files)
{ {
var src = path2node[f]; var src = path2node[f];
var dir = Path.GetDirectoryName(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)) 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); } 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]); 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); 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); var nodes = new HashSet<string>(path2node.Values);
nodes.UnionWith(extDllSet);
if (cfg.ExcludeSamples) if (cfg.ExcludeSamples)
{ {
var markers = cfg.SampleMarkers; var markers = cfg.SampleMarkers;
bool IsSample(string node) bool IsSample(string node)
{ {
var f = path2node.First(kv => kv.Value == node).Key.ToLowerInvariant(); if (!node2path.TryGetValue(node, out var f)) return false; // 경로 없는 노드(외부 DLL)는 유지
var nl = node.ToLowerInvariant(); var fl = f.ToLowerInvariant(); var nl = node.ToLowerInvariant();
return markers.Any(mk => f.Contains(mk) || nl.Contains(mk)); return markers.Any(mk => fl.Contains(mk) || nl.Contains(mk));
} }
nodes = new HashSet<string>(nodes.Where(n => !IsSample(n))); nodes = new HashSet<string>(nodes.Where(n => !IsSample(n)));
} }
@@ -232,8 +315,8 @@ public static class DsmEngine
// 그룹핑 // 그룹핑
string TopFolder(string node) string TopFolder(string node)
{ {
var f = path2node.First(kv => kv.Value == node).Key; if (!node2path.TryGetValue(node, out var f)) return "(외부 DLL)";
var rel = Path.GetRelativePath(root, f).Replace('/', '\\').Split('\\'); var rel = Path.GetRelativePath(scanBase, f).Replace('/', '\\').Split('\\');
return rel.Length > 1 ? rel[0] : "(root)"; return rel.Length > 1 ? rel[0] : "(root)";
} }
var mode = cfg.GroupMode; var mode = cfg.GroupMode;
@@ -249,7 +332,7 @@ public static class DsmEngine
"prefix" => n => NamePrefix(nodeBase[n]), "prefix" => n => NamePrefix(nodeBase[n]),
_ => TopFolder, _ => 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(); 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" }; string[] pal = { "#2ea043", "#1f6feb", "#8957e5", "#d29922", "#0969da", "#e36209", "#1a7f37", "#cf222e", "#6e7781", "#bf8700", "#a371f7", "#bc4c00", "#3fb950", "#db61a2", "#c9510c", "#0550ae" };
var gcol = new Dictionary<string, string>(); var gcol = new Dictionary<string, string>();
+8 -6
View File
@@ -25,19 +25,21 @@
</Grid> </Grid>
<!-- Options --> <!-- Options -->
<StackPanel Grid.Row="3" Orientation="Horizontal" Margin="90,0,0,4" Spacing="18"> <WrapPanel Grid.Row="3" Margin="90,0,0,4">
<CheckBox x:Name="SamplesBox" IsChecked="True" Content="Sample/Test/Example 제외"/> <CheckBox x:Name="SamplesBox" IsChecked="True" Content="Sample/Test 제외" Margin="0,0,16,4"/>
<CheckBox x:Name="AutoInternalBox" IsChecked="True" Content="내부 NuGet 자동 감지"/> <CheckBox x:Name="AutoInternalBox" IsChecked="True" Content="내부 NuGet 자동감지" Margin="0,0,16,4"/>
<StackPanel Orientation="Horizontal" Spacing="6"> <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"/> <TextBlock Text="그룹:" VerticalAlignment="Center"/>
<ComboBox x:Name="GroupCombo" Width="130" SelectedIndex="0"> <ComboBox x:Name="GroupCombo" Width="120" SelectedIndex="0">
<ComboBoxItem>auto</ComboBoxItem> <ComboBoxItem>auto</ComboBoxItem>
<ComboBoxItem>topfolder</ComboBoxItem> <ComboBoxItem>topfolder</ComboBoxItem>
<ComboBoxItem>prefix</ComboBoxItem> <ComboBoxItem>prefix</ComboBoxItem>
<ComboBoxItem>none</ComboBoxItem> <ComboBoxItem>none</ComboBoxItem>
</ComboBox> </ComboBox>
</StackPanel> </StackPanel>
</StackPanel> </WrapPanel>
<Expander Grid.Row="4" Header="고급 설정 (제외 경로 · 수동 패키지 매핑)" Margin="0,4,0,8"> <Expander Grid.Row="4" Header="고급 설정 (제외 경로 · 수동 패키지 매핑)" Margin="0,4,0,8">
<StackPanel Spacing="6" Margin="4"> <StackPanel Spacing="6" Margin="4">
+2
View File
@@ -71,6 +71,8 @@ public partial class MainWindow : Window
{ {
ExcludeSamples = SamplesBox.IsChecked == true, ExcludeSamples = SamplesBox.IsChecked == true,
InternalAuto = AutoInternalBox.IsChecked == true, InternalAuto = AutoInternalBox.IsChecked == true,
FollowExternal = FollowBox.IsChecked == true,
ExternalDlls = ExtDllBox.IsChecked == true,
GroupMode = (GroupCombo.SelectedItem as ContentControl)?.Content?.ToString() ?? "auto", GroupMode = (GroupCombo.SelectedItem as ContentControl)?.Content?.ToString() ?? "auto",
ExcludePaths = SplitLines(ExcludeBox.Text).Select(x => x.ToLowerInvariant()).ToList(), ExcludePaths = SplitLines(ExcludeBox.Text).Select(x => x.ToLowerInvariant()).ToList(),
ManualMap = ParseMap(SplitLines(ComponentBox.Text)), ManualMap = ParseMap(SplitLines(ComponentBox.Text)),
+6 -1
View File
@@ -17,9 +17,14 @@
노드는 위상 정렬(공급자 먼저)되며, 순환이 없으면 하삼각 행렬이 된다(상삼각 위반 0 = 비순환 = 빌드 순서). 노드는 위상 정렬(공급자 먼저)되며, 순환이 없으면 하삼각 행렬이 된다(상삼각 위반 0 = 비순환 = 빌드 순서).
동일 파일명이 여러 개면 상대경로로 자동 구분한다. 동일 파일명이 여러 개면 상대경로로 자동 구분한다.
**루트 밖 참조도 분석**(중요): 선택한 폴더 밖에 있는 프로젝트/DLL도 포함한다.
- `follow`(기본 ON): 루트 밖 **ProjectReference 를 전이적으로 따라가** 그 프로젝트(및 그 의존)까지 노드로 포함.
- `externaldll`(기본 ON): `HintPath` 로 참조하는 외부 **DLL**`(외부 DLL)` 노드로 포함.
- 그룹/노드명 기준은 포함된 모든 파일의 **공통 상위 폴더**로 자동 설정(예: 여러 폴더에 걸치면 `Common`/`Product`/`(외부 DLL)` 등으로 분리).
## 사용법 (GUI) ## 사용법 (GUI)
1. `DsmTool.exe` 실행 → **대상 폴더** 선택(필요 시 **출력 폴더**). 1. `DsmTool.exe` 실행 → **대상 폴더** 선택(필요 시 **출력 폴더**).
2. 옵션: *Sample 제외*, *내부 NuGet 자동 감지*, **그룹 모드**(auto/topfolder/prefix/none). 2. 옵션: *Sample 제외* · *내부 NuGet 자동감지* · *루트 밖 프로젝트 따라가기* · *외부 DLL 포함* · **그룹 모드**.
*고급 설정* 에서 제외 경로·수동 패키지 매핑 조정. *고급 설정* 에서 제외 경로·수동 패키지 매핑 조정.
3. **생성**`{분석폴더명}_DependencyMetrix.html`, `{분석폴더명}_DependencyMetrix.csv` 3. **생성**`{분석폴더명}_DependencyMetrix.html`, `{분석폴더명}_DependencyMetrix.csv`
(예: `D:\ZOO` 분석 시 `ZOO_DependencyMetrix.html`). (예: `D:\ZOO` 분석 시 `ZOO_DependencyMetrix.html`).
+9 -1
View File
@@ -28,9 +28,17 @@ internal = auto
# map = TeighaCodePack_22.12_16 = TeighaCodePack (Component/TeighaCodePack/Ver_22.12_16) # map = TeighaCodePack_22.12_16 = TeighaCodePack (Component/TeighaCodePack/Ver_22.12_16)
# map = MyInternalPkg = MyProject # map = MyInternalPkg = MyProject
# --- 루트 밖 참조 처리 ----------------------------------------------
# follow : 루트 밖 ProjectReference 를 전이적으로 따라가 포함
# (다른 폴더에 있는 프로젝트도 분석). 끄려면 follow = false
# externaldll : Reference HintPath 로 참조하는 외부 DLL 을 (외부 DLL) 노드로 포함
# 끄려면 externaldll = false
follow = true
externaldll = true
# --- 그룹(색상) 모드 ------------------------------------------------- # --- 그룹(색상) 모드 -------------------------------------------------
# auto : 최상위폴더로 묶되, 평면 배치면 이름 접두어로 자동 전환 # auto : 최상위폴더로 묶되, 평면 배치면 이름 접두어로 자동 전환
# topfolder : 루트 바로 아래 폴더 # topfolder : (포함된 파일들의 공통 상위폴더) 바로 아래 폴더
# prefix : 프로젝트명 접두어(Eg, Editor ...) # prefix : 프로젝트명 접두어(Eg, Editor ...)
# none : 단일 그룹 # none : 단일 그룹
group = auto group = auto