diff --git a/exe/DsmTool.exe b/exe/DsmTool.exe index 6c19fca..8e1269c 100644 Binary files a/exe/DsmTool.exe and b/exe/DsmTool.exe differ diff --git a/src/DsmTool/DsmEngine.cs b/src/DsmTool/DsmEngine.cs index 94a87f0..4659823 100644 --- a/src/DsmTool/DsmEngine.cs +++ b/src/DsmTool/DsmEngine.cs @@ -17,6 +17,8 @@ public sealed class DsmConfig public bool InternalAuto = true; // PackageReference↔프로젝트명 자동 매칭 public Dictionary 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 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 log) { root = Path.GetFullPath(root); var excl = cfg.ExcludePaths; var eo = new EnumerationOptions { RecurseSubdirectories = true, IgnoreInaccessible = true }; - var files = new List(); + bool Excluded(string p) { var l = p.ToLowerInvariant(); return excl.Any(x => l.Contains(x)); } + var textCache = new Dictionary(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(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(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(); + var extDllSet = new HashSet(); + 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(); nodeBase[lab] = name; + return lab; + } + var reRefBlock = new Regex("]*>(.*?)", RegexOptions.IgnoreCase | RegexOptions.Singleline); + var reHint = new Regex("\\s*([^<]+?)\\s*", 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(); + foreach (var kv in path2node) node2path[kv.Value] = kv.Key; + // 샘플 제외 var nodes = new HashSet(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(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(); diff --git a/src/DsmTool/MainWindow.axaml b/src/DsmTool/MainWindow.axaml index 49c66c7..dd1a3aa 100644 --- a/src/DsmTool/MainWindow.axaml +++ b/src/DsmTool/MainWindow.axaml @@ -25,19 +25,21 @@ - - - - + + + + + + - + auto topfolder prefix none - + diff --git a/src/DsmTool/MainWindow.axaml.cs b/src/DsmTool/MainWindow.axaml.cs index d7f2d0a..ebef82d 100644 --- a/src/DsmTool/MainWindow.axaml.cs +++ b/src/DsmTool/MainWindow.axaml.cs @@ -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)), diff --git a/src/DsmTool/README.md b/src/DsmTool/README.md index f99d093..64ffe7e 100644 --- a/src/DsmTool/README.md +++ b/src/DsmTool/README.md @@ -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`). diff --git a/src/DsmTool/dsm.config.sample.ini b/src/DsmTool/dsm.config.sample.ini index ec78ded..b899afe 100644 --- a/src/DsmTool/dsm.config.sample.ini +++ b/src/DsmTool/dsm.config.sample.ini @@ -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