commit 8250efbe57a32e6be760675f0d8c2f9253ad4b2b Author: b17314 Date: Wed Jul 8 11:04:56 2026 +0900 DsmTool: 프로젝트 의존성 매트릭스(DSM) 생성기 - .NET/C++ 저장소를 스캔해 ProjectReference/내부 Reference/내부 NuGet 의존을 파싱하고 DSM(HTML+CSV)을 생성하는 GUI/CLI 도구 - Avalonia + NativeAOT 단일 exe(완전 무설치), dsm.config.ini 팀별 설정 지원 - 내부 NuGet 자동 감지, 위상 정렬(순환 검출), 그룹 색상(auto/topfolder/prefix) Co-Authored-By: Claude Opus 4.8 (1M context) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b5e956f --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# 로컬 설정/시크릿 +configs/ +.mcp.json + +# .NET 빌드 산출물 +bin/ +obj/ +*.user + +# 기타 +*.log +Thumbs.db diff --git a/exe/DsmTool.exe b/exe/DsmTool.exe new file mode 100644 index 0000000..9179343 Binary files /dev/null and b/exe/DsmTool.exe differ diff --git a/src/DsmTool/App.axaml b/src/DsmTool/App.axaml new file mode 100644 index 0000000..2a1b56d --- /dev/null +++ b/src/DsmTool/App.axaml @@ -0,0 +1,8 @@ + + + + + diff --git a/src/DsmTool/App.axaml.cs b/src/DsmTool/App.axaml.cs new file mode 100644 index 0000000..854c7f6 --- /dev/null +++ b/src/DsmTool/App.axaml.cs @@ -0,0 +1,18 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; + +namespace DsmTool; + +public partial class App : Application +{ + public override void Initialize() => AvaloniaXamlLoader.Load(this); + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + desktop.MainWindow = new MainWindow(); + + base.OnFrameworkInitializationCompleted(); + } +} diff --git a/src/DsmTool/DsmEngine.cs b/src/DsmTool/DsmEngine.cs new file mode 100644 index 0000000..9e8ea93 --- /dev/null +++ b/src/DsmTool/DsmEngine.cs @@ -0,0 +1,393 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; + +namespace DsmTool; + +public sealed class DsmConfig +{ + // 제외 경로 조각(소문자 부분일치). 기본은 빌드 산출물만 — 저장소별 항목은 사용자가 추가. + public List ExcludePaths = new(DefaultExcludePaths); + public bool ExcludeSamples = true; + // 샘플 판정 substring(소문자) — 노드명/경로에 포함되면 샘플로 간주 + public List SampleMarkers = new(DefaultSampleMarkers); + public bool InternalAuto = true; // PackageReference↔프로젝트명 자동 매칭 + public Dictionary ManualMap = new(); // 수동 pkgid→노드명(자동에 추가/보정) + public string GroupMode = "auto"; // auto | topfolder | prefix | none + + public static readonly string[] DefaultExcludePaths = + { + @"\bin\", @"\obj\", @"\.vs\", @"\.git\", "_wpftmp", + @"\packages\", @"\node_modules\", @"\testresults\", + @"\_backup\", @"\.old\" + }; + public static readonly string[] DefaultSampleMarkers = + { + "sample", "example", "demo", ".test", "\\test", "테스트", "쓰레기통", "temp" + }; + + // ---- ini 로더: key = value (반복 key=목록), '#'/';' 주석 ---- + public static DsmConfig Load(string? iniPath) + { + var c = new DsmConfig(); + if (iniPath == null || !File.Exists(iniPath)) return c; + bool exclReset = false; var excl = new List(); var samp = new List(); + foreach (var raw in File.ReadAllLines(iniPath)) + { + var line = raw.Trim(); + if (line.Length == 0 || line[0] == '#' || line[0] == ';') continue; + var eq = line.IndexOf('='); + if (eq <= 0) continue; + var k = line[..eq].Trim().ToLowerInvariant(); + var v = line[(eq + 1)..].Trim(); + switch (k) + { + case "exclude": excl.Add(v.ToLowerInvariant()); break; + case "excludereset": exclReset = v.Equals("true", StringComparison.OrdinalIgnoreCase); break; + case "excludesamples": c.ExcludeSamples = v.Equals("true", StringComparison.OrdinalIgnoreCase); break; + 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 "map": + var i = v.IndexOf('='); + if (i > 0) c.ManualMap[v[..i].Trim().ToLowerInvariant()] = v[(i + 1)..].Trim(); + break; + } + } + if (excl.Count > 0) c.ExcludePaths = exclReset ? excl : DefaultExcludePaths.Concat(excl).ToList(); + if (samp.Count > 0) c.SampleMarkers = samp; + return c; + } +} + +public sealed class DsmResult +{ + public int Nodes, Edges, Upper, MaxFanInV, MaxFanOutV; + public string HtmlPath = "", CsvPath = "", MaxFanIn = "", MaxFanOut = ""; +} + +public static class DsmEngine +{ + // ---------- helpers ---------- + private static string ReadText(string p) + { + var b = File.ReadAllBytes(p); + if (b.Length >= 3 && b[0] == 0xEF && b[1] == 0xBB && b[2] == 0xBF) return Encoding.UTF8.GetString(b, 3, b.Length - 3); + if (b.Length >= 2 && b[0] == 0xFF && b[1] == 0xFE) return Encoding.Unicode.GetString(b, 2, b.Length - 2); + if (b.Length >= 2 && b[0] == 0xFE && b[1] == 0xFF) return Encoding.BigEndianUnicode.GetString(b, 2, b.Length - 2); + try { return new UTF8Encoding(false, true).GetString(b); } + catch { return Encoding.Latin1.GetString(b); } + } + + private static readonly Regex VerToken = new(@"[_\.]?\d+\.\d+[_\.]?\d*", RegexOptions.Compiled); + private static string Norm(string n) => VerToken.Replace(n.ToLowerInvariant(), "").Trim('.', '_', ' '); + + private static readonly Regex WordPrefix = new(@"^[A-Za-z][a-z]+", RegexOptions.Compiled); + private static string NamePrefix(string baseName) + { + var m = WordPrefix.Match(baseName); + if (m.Success && m.Length >= 2) return m.Value; + // 대문자 약어/기타: 첫 숫자·구분자 전까지, 최대 10자 + var s = new string(baseName.TakeWhile(ch => char.IsLetter(ch)).ToArray()); + return s.Length == 0 ? "기타" : (s.Length > 10 ? s[..10] : s); + } + + // ---------- 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(); + 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)); + } + files = files.Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList(); + log($"프로젝트 파일 {files.Count}개 (제외 규칙 적용 후)"); + + // 노드명: 기본은 파일명. 동일 파일명 충돌 시 상대 디렉터리로 자동 구분. + var baseName = files.ToDictionary(f => f, f => Path.GetFileNameWithoutExtension(f), StringComparer.OrdinalIgnoreCase); + var dupBases = baseName.Values.GroupBy(x => x, StringComparer.OrdinalIgnoreCase) + .Where(g => g.Count() > 1).Select(g => g.Key) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var path2node = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var f in files) + { + var b = baseName[f]; + if (dupBases.Contains(b)) + { + var relDir = Path.GetDirectoryName(Path.GetRelativePath(root, f)) ?? ""; + path2node[f] = $"{b} ({relDir.Replace('\\', '/')})"; + } + else path2node[f] = b; + } + var nodeBase = path2node.ToDictionary(kv => kv.Value, kv => baseName[kv.Key]); // 노드→원래파일명 + + // 이름 매칭용 인덱스 (내부 Reference / PackageReference) + var byExact = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var byNorm = new Dictionary>(); + foreach (var kv in path2node) + { + var b = baseName[kv.Key]; + (byExact.TryGetValue(b, out var le) ? le : byExact[b] = new()).Add(kv.Value); + var nk = Norm(b); + (byNorm.TryGetValue(nk, out var ln) ? ln : byNorm[nk] = new()).Add(kv.Value); + } + // 프로젝트가 선언한 PackageId 도 매칭 후보에 포함 + var rePkgId = new Regex(@"\s*([^<]+?)\s*", RegexOptions.IgnoreCase); + + var edges = new Dictionary>(); + foreach (var n in path2node.Values) edges[n] = new HashSet(); + + var reProj = new Regex("ProjectReference\\s+Include\\s*=\\s*\"([^\"]+)\"", RegexOptions.IgnoreCase); + var reRef = new Regex("(path2node.Values); + 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)); + } + nodes = new HashSet(nodes.Where(n => !IsSample(n))); + } + var e2 = new Dictionary>(); + foreach (var n in nodes) e2[n] = new HashSet(edges[n].Where(nodes.Contains)); + edges = e2; + + // 위상 정렬(의존 대상 먼저) + var order = new List(); var seen = new HashSet(); + void Visit(string n, HashSet stk) + { + if (seen.Contains(n) || stk.Contains(n)) return; + stk.Add(n); + foreach (var t in edges[n].OrderBy(x => x, StringComparer.Ordinal)) Visit(t, stk); + stk.Remove(n); seen.Add(n); order.Add(n); + } + foreach (var n in nodes.OrderBy(x => edges[x].Count > 0).ThenBy(x => x, StringComparer.Ordinal)) Visit(n, new HashSet()); + var idx = new Dictionary(); + for (int i = 0; i < order.Count; i++) idx[order[i]] = i; + int N = order.Count; + + var fout = order.ToDictionary(n => n, n => edges[n].Count); + var fin = order.ToDictionary(n => n, _ => 0); + foreach (var n in order) foreach (var t in edges[n]) fin[t]++; + int upper = order.Sum(a => edges[a].Count(b => idx[b] > idx[a])); + + // 그룹핑 + string TopFolder(string node) + { + var f = path2node.First(kv => kv.Value == node).Key; + var rel = Path.GetRelativePath(root, f).Replace('/', '\\').Split('\\'); + return rel.Length > 1 ? rel[0] : "(root)"; + } + var mode = cfg.GroupMode; + if (mode == "auto") + { + var tf = order.Select(TopFolder).Distinct().Count(); + mode = (N > 0 && tf > Math.Max(8, N * 0.5)) ? "prefix" : "topfolder"; + log($"그룹 모드 auto → {mode} (최상위폴더 {tf}종)"); + } + Func groupOf = mode switch + { + "none" => _ => "all", + "prefix" => n => NamePrefix(nodeBase[n]), + _ => TopFolder, + }; + var group = order.ToDictionary(n => n, groupOf); + 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(); + for (int i = 0; i < gnames.Count; i++) gcol[gnames[i]] = pal[i % pal.Length]; + + Directory.CreateDirectory(outDir); + + // CSV + var sb = new StringBuilder(); + sb.Append("idx,node,group,").Append(string.Join(",", Enumerable.Range(1, N))).Append('\n'); + for (int i = 0; i < N; i++) + { + var n = order[i]; + sb.Append(i + 1).Append(",\"").Append(n.Replace("\"", "\"\"")).Append("\",\"").Append(group[n].Replace("\"", "\"\"")).Append('"'); + for (int j = 0; j < N; j++) { sb.Append(','); sb.Append(i == j ? "\\" : (edges[n].Contains(order[j]) ? "1" : "")); } + sb.Append('\n'); + } + var csvPath = Path.Combine(outDir, "dependency_matrix.csv"); + File.WriteAllText(csvPath, sb.ToString(), new UTF8Encoding(false)); + + var htmlPath = Path.Combine(outDir, "dependency_matrix.html"); + File.WriteAllText(htmlPath, BuildHtml(order, edges, group, gcol, gnames, fin, fout, root, N, upper), new UTF8Encoding(false)); + + string maxFi = N > 0 ? order.OrderByDescending(n => fin[n]).First() : ""; + string maxFo = N > 0 ? order.OrderByDescending(n => fout[n]).First() : ""; + return new DsmResult + { + Nodes = N, + Edges = fout.Values.Sum(), + Upper = upper, + HtmlPath = htmlPath, + CsvPath = csvPath, + MaxFanIn = maxFi, + MaxFanInV = maxFi != "" ? fin[maxFi] : 0, + MaxFanOut = maxFo, + MaxFanOutV = maxFo != "" ? fout[maxFo] : 0 + }; + } + + // ---------- output builders ---------- + private static string HtmlEsc(string s) => s.Replace("&", "&").Replace("<", "<").Replace(">", ">"); + + private static string JStr(string s) + { + var sb = new StringBuilder("\""); + foreach (char c in s) + { + if (c == '\\' || c == '"') { sb.Append('\\'); sb.Append(c); } + else if (c == '\n') sb.Append("\\n"); + else if (c < ' ') sb.Append("\\u").Append(((int)c).ToString("x4")); + else sb.Append(c); + } + return sb.Append('"').ToString(); + } + private static string JArr(IEnumerable xs) => "[" + string.Join(",", xs.Select(JStr)) + "]"; + private static string JArrI(IEnumerable xs) => "[" + string.Join(",", xs) + "]"; + private static string JDict(Dictionary d) => "{" + string.Join(",", d.Select(kv => JStr(kv.Key) + ":" + JStr(kv.Value))) + "}"; + + private static string BuildHtml( + List order, Dictionary> edges, + Dictionary group, Dictionary gcol, List gnames, + Dictionary fin, Dictionary fout, string root, int N, int upper) + { + var mat = new StringBuilder("["); + for (int i = 0; i < N; i++) + { + if (i > 0) mat.Append(','); + mat.Append('['); + for (int j = 0; j < N; j++) + { + if (j > 0) mat.Append(','); + mat.Append(i != j && edges[order[i]].Contains(order[j]) ? '1' : '0'); + } + mat.Append(']'); + } + mat.Append(']'); + + var leg = new StringBuilder(); + foreach (var g in gnames) + leg.Append("").Append(HtmlEsc(g)).Append(""); + + int maxFi = N > 0 ? order.Max(n => fin[n]) : 0; + int maxFo = N > 0 ? order.Max(n => fout[n]) : 0; + + return Template + .Replace("__ROOT__", HtmlEsc(root)) + .Replace("__N__", N.ToString()) + .Replace("__EDGES__", order.Sum(n => edges[n].Count).ToString()) + .Replace("__UPPER__", upper.ToString()) + .Replace("__MAXFI__", maxFi.ToString()) + .Replace("__MAXFO__", maxFo.ToString()) + .Replace("__LEGEND__", leg.ToString()) + .Replace("__R__", JArr(order)) + .Replace("__G__", JArr(order.Select(n => group.GetValueOrDefault(n, "")))) + .Replace("__M__", mat.ToString()) + .Replace("__FI__", JArrI(order.Select(n => fin[n]))) + .Replace("__FO__", JArrI(order.Select(n => fout[n]))) + .Replace("__GC__", JDict(gcol)); + } + + private const string Template = @" +의존성 매트릭스 (DSM) +
+

의존성 매트릭스 (DSM)

+

root __ROOT__ · 행 → 열(행이 열에 의존) · 위상 정렬 · ProjectReference/내부 Reference/내부 NuGet 자동 파싱

+
+
__N__프로젝트
__EDGES__내부 간선
+
__UPPER__순환(상삼각) 위반
+
__MAXFI__최대 Fan-in
__MAXFO__최대 Fan-out
+
__LEGEND__
+
+

읽는 법 · 파란 칸 = 행 프로젝트가 열 프로젝트에 의존. 대각선 = 자기 자신. 행/열 hover 시 교차 강조. 표식이 모두 대각선 아래면(상삼각 위반 0) 순환 없음 → 위→아래가 빌드 순서.

+"; +} diff --git a/src/DsmTool/DsmTool.csproj b/src/DsmTool/DsmTool.csproj new file mode 100644 index 0000000..79913d7 --- /dev/null +++ b/src/DsmTool/DsmTool.csproj @@ -0,0 +1,33 @@ + + + + WinExe + net9.0 + enable + true + app.manifest + true + DsmTool + DsmTool + + + win-x64 + true + true + true + + + + + + + + + + + + + + + + diff --git a/src/DsmTool/MainWindow.axaml b/src/DsmTool/MainWindow.axaml new file mode 100644 index 0000000..49c66c7 --- /dev/null +++ b/src/DsmTool/MainWindow.axaml @@ -0,0 +1,72 @@ + + + + + + + + + + +