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) <noreply@anthropic.com>
This commit is contained in:
+12
@@ -0,0 +1,12 @@
|
|||||||
|
# 로컬 설정/시크릿
|
||||||
|
configs/
|
||||||
|
.mcp.json
|
||||||
|
|
||||||
|
# .NET 빌드 산출물
|
||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
*.user
|
||||||
|
|
||||||
|
# 기타
|
||||||
|
*.log
|
||||||
|
Thumbs.db
|
||||||
Binary file not shown.
@@ -0,0 +1,8 @@
|
|||||||
|
<Application xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
x:Class="DsmTool.App"
|
||||||
|
RequestedThemeVariant="Default">
|
||||||
|
<Application.Styles>
|
||||||
|
<FluentTheme />
|
||||||
|
</Application.Styles>
|
||||||
|
</Application>
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string> ExcludePaths = new(DefaultExcludePaths);
|
||||||
|
public bool ExcludeSamples = true;
|
||||||
|
// 샘플 판정 substring(소문자) — 노드명/경로에 포함되면 샘플로 간주
|
||||||
|
public List<string> SampleMarkers = new(DefaultSampleMarkers);
|
||||||
|
public bool InternalAuto = true; // PackageReference↔프로젝트명 자동 매칭
|
||||||
|
public Dictionary<string, string> 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<string>(); var samp = new List<string>();
|
||||||
|
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<string> log)
|
||||||
|
{
|
||||||
|
root = Path.GetFullPath(root);
|
||||||
|
var excl = cfg.ExcludePaths;
|
||||||
|
var eo = new EnumerationOptions { RecurseSubdirectories = true, IgnoreInaccessible = true };
|
||||||
|
var files = new List<string>();
|
||||||
|
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<string, string>(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<string, List<string>>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var byNorm = new Dictionary<string, List<string>>();
|
||||||
|
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(@"<PackageId>\s*([^<]+?)\s*</PackageId>", RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
var edges = new Dictionary<string, HashSet<string>>();
|
||||||
|
foreach (var n in path2node.Values) edges[n] = new HashSet<string>();
|
||||||
|
|
||||||
|
var reProj = new Regex("ProjectReference\\s+Include\\s*=\\s*\"([^\"]+)\"", RegexOptions.IgnoreCase);
|
||||||
|
var reRef = new Regex("<Reference\\s+Include\\s*=\\s*\"([^\"]+)\"", RegexOptions.IgnoreCase);
|
||||||
|
var rePkg = new Regex("PackageReference\\s+Include\\s*=\\s*\"([^\"]+)\"", RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
|
// PackageId 선언 수집
|
||||||
|
foreach (var f in files)
|
||||||
|
{
|
||||||
|
string txt; try { txt = ReadText(f); } catch { continue; }
|
||||||
|
var pm = rePkgId.Match(txt);
|
||||||
|
if (pm.Success)
|
||||||
|
{
|
||||||
|
var id = pm.Groups[1].Value.Trim();
|
||||||
|
(byExact.TryGetValue(id, out var l) ? l : byExact[id] = new()).Add(path2node[f]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void LinkPackage(string src, string id)
|
||||||
|
{
|
||||||
|
id = id.Trim();
|
||||||
|
if (cfg.ManualMap.TryGetValue(id.ToLowerInvariant(), out var forced))
|
||||||
|
{ if (edges.ContainsKey(forced) && forced != src) edges[src].Add(forced); return; }
|
||||||
|
if (!cfg.InternalAuto) return;
|
||||||
|
if (byExact.TryGetValue(id, out var ex)) { foreach (var t in ex) if (t != src) edges[src].Add(t); return; }
|
||||||
|
if (byNorm.TryGetValue(Norm(id), out var nm) && nm.Count == 1 && nm[0] != src) edges[src].Add(nm[0]);
|
||||||
|
// 정규화 매칭이 다중이면 모호 → 링크 생략(오탐 방지)
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var f in files)
|
||||||
|
{
|
||||||
|
var src = path2node[f];
|
||||||
|
var dir = Path.GetDirectoryName(f)!;
|
||||||
|
string txt; try { txt = ReadText(f); } catch { continue; }
|
||||||
|
|
||||||
|
foreach (Match m in reProj.Matches(txt))
|
||||||
|
{
|
||||||
|
string tgt; try { tgt = Path.GetFullPath(Path.Combine(dir, m.Groups[1].Value.Replace('\\', '/'))); } catch { continue; }
|
||||||
|
if (path2node.TryGetValue(tgt, out var tn) && tn != src) edges[src].Add(tn);
|
||||||
|
}
|
||||||
|
foreach (Match m in reRef.Matches(txt))
|
||||||
|
{
|
||||||
|
var inc = m.Groups[1].Value.Split(',')[0].Trim();
|
||||||
|
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]);
|
||||||
|
}
|
||||||
|
foreach (Match m in rePkg.Matches(txt)) LinkPackage(src, m.Groups[1].Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 샘플 제외
|
||||||
|
var nodes = new HashSet<string>(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<string>(nodes.Where(n => !IsSample(n)));
|
||||||
|
}
|
||||||
|
var e2 = new Dictionary<string, HashSet<string>>();
|
||||||
|
foreach (var n in nodes) e2[n] = new HashSet<string>(edges[n].Where(nodes.Contains));
|
||||||
|
edges = e2;
|
||||||
|
|
||||||
|
// 위상 정렬(의존 대상 먼저)
|
||||||
|
var order = new List<string>(); var seen = new HashSet<string>();
|
||||||
|
void Visit(string n, HashSet<string> 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<string>());
|
||||||
|
var idx = new Dictionary<string, int>();
|
||||||
|
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<string, string> 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<string, string>();
|
||||||
|
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<string> xs) => "[" + string.Join(",", xs.Select(JStr)) + "]";
|
||||||
|
private static string JArrI(IEnumerable<int> xs) => "[" + string.Join(",", xs) + "]";
|
||||||
|
private static string JDict(Dictionary<string, string> d) => "{" + string.Join(",", d.Select(kv => JStr(kv.Key) + ":" + JStr(kv.Value))) + "}";
|
||||||
|
|
||||||
|
private static string BuildHtml(
|
||||||
|
List<string> order, Dictionary<string, HashSet<string>> edges,
|
||||||
|
Dictionary<string, string> group, Dictionary<string, string> gcol, List<string> gnames,
|
||||||
|
Dictionary<string, int> fin, Dictionary<string, int> 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("<span class='chip'><i style='background:").Append(gcol[g]).Append("'></i>").Append(HtmlEsc(g)).Append("</span>");
|
||||||
|
|
||||||
|
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 = @"<!doctype html><html lang='ko'><head><meta charset='utf-8'>
|
||||||
|
<meta name='viewport' content='width=device-width,initial-scale=1'><title>의존성 매트릭스 (DSM)</title>
|
||||||
|
<style>
|
||||||
|
:root{--bg:#fff;--fg:#1f2328;--mut:#57606a;--line:#d0d7de;--diag:#57606a;--hl:#fff3cd;--panel:#f6f8fa;--dep:#0969da}
|
||||||
|
@media(prefers-color-scheme:dark){:root{--bg:#0d1117;--fg:#e6edf3;--mut:#8b949e;--line:#30363d;--diag:#484f58;--hl:#3d3417;--panel:#161b22;--dep:#58a6ff}}
|
||||||
|
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:13px/1.5 'Segoe UI','Malgun Gothic',sans-serif}
|
||||||
|
.wrap{max-width:1600px;margin:0 auto;padding:24px}h1{font-size:20px;margin:0 0 4px}.sub{color:var(--mut);font-size:12.5px;margin:0 0 14px}
|
||||||
|
.stats{display:flex;flex-wrap:wrap;gap:10px;margin:12px 0}.stat{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:8px 12px}
|
||||||
|
.stat b{font-size:17px}.stat span{color:var(--mut);font-size:11.5px;display:block}
|
||||||
|
.legend{display:flex;flex-wrap:wrap;gap:8px;margin:10px 0 16px}.chip{display:inline-flex;align-items:center;gap:6px;background:var(--panel);border:1px solid var(--line);border-radius:20px;padding:3px 10px;font-size:11.5px}.chip i{width:10px;height:10px;border-radius:2px}
|
||||||
|
.scroll{overflow:auto;border:1px solid var(--line);border-radius:8px;max-height:88vh}table{border-collapse:collapse;font-size:11px}th,td{padding:0;text-align:center}
|
||||||
|
td.cell{width:15px;height:15px;min-width:15px;border-right:1px solid var(--line);border-bottom:1px solid var(--line)}td.dep{background:var(--dep)}td.diag{background:var(--diag)}
|
||||||
|
th.col{height:170px;width:15px;min-width:15px;position:sticky;top:0;background:var(--bg);z-index:2;border-bottom:1px solid var(--line)}th.col div{transform:rotate(-90deg);white-space:nowrap;width:15px;font-size:9.5px;color:var(--mut)}
|
||||||
|
th.corner{position:sticky;top:0;left:0;z-index:4;background:var(--bg)}
|
||||||
|
th.row{position:sticky;left:0;background:var(--bg);z-index:1;text-align:left;white-space:nowrap;padding:0 8px;height:15px;border-bottom:1px solid var(--line);border-right:2px solid var(--line);font-weight:400;max-width:340px;overflow:hidden;text-overflow:ellipsis}
|
||||||
|
th.row .gi{display:inline-block;width:8px;height:8px;border-radius:2px;margin-right:6px}th.row .ix{color:var(--mut);display:inline-block;width:30px;text-align:right;margin-right:6px}
|
||||||
|
tr.hl th.row,tr.hl td.cell{background:var(--hl)}td.cell.hlc{background:var(--hl)}td.dep.hlc{background:var(--dep)}.fan{color:var(--mut);font-size:9.5px;margin-left:6px}
|
||||||
|
.note{color:var(--mut);font-size:12px;margin-top:14px;max-width:900px}.note b{color:var(--fg)}
|
||||||
|
</style></head><body><div class='wrap'>
|
||||||
|
<h1>의존성 매트릭스 (DSM)</h1>
|
||||||
|
<p class='sub'>root <code>__ROOT__</code> · 행 → 열(행이 열에 의존) · 위상 정렬 · ProjectReference/내부 Reference/내부 NuGet 자동 파싱</p>
|
||||||
|
<div class='stats'>
|
||||||
|
<div class='stat'><b>__N__</b><span>프로젝트</span></div><div class='stat'><b>__EDGES__</b><span>내부 간선</span></div>
|
||||||
|
<div class='stat'><b>__UPPER__</b><span>순환(상삼각) 위반</span></div>
|
||||||
|
<div class='stat'><b>__MAXFI__</b><span>최대 Fan-in</span></div><div class='stat'><b>__MAXFO__</b><span>최대 Fan-out</span></div>
|
||||||
|
</div><div class='legend'>__LEGEND__</div>
|
||||||
|
<div class='scroll'><table id='dsm'></table></div>
|
||||||
|
<p class='note'><b>읽는 법</b> · 파란 칸 = 행 프로젝트가 열 프로젝트에 의존. 대각선 = 자기 자신. 행/열 hover 시 교차 강조. 표식이 모두 대각선 아래면(상삼각 위반 0) 순환 없음 → 위→아래가 빌드 순서.</p></div>
|
||||||
|
<script>
|
||||||
|
const R=__R__,G=__G__,M=__M__,FI=__FI__,FO=__FO__,GC=__GC__;
|
||||||
|
const N=R.length,t=document.getElementById('dsm');let h='<tr><th class=corner></th>';
|
||||||
|
for(let j=0;j<N;j++)h+=`<th class='col'><div>${j+1}. ${R[j].replace(/</g,'<')}</div></th>`;h+='</tr>';
|
||||||
|
for(let i=0;i<N;i++){h+=`<tr data-r='${i}'><th class='row'><span class=ix>${i+1}</span><span class=gi style='background:${GC[G[i]]}'></span>${R[i].replace(/</g,'<')}<span class=fan>${FO[i]}/${FI[i]}</span></th>`;
|
||||||
|
for(let j=0;j<N;j++){h+=i===j?`<td class='cell diag'></td>`:`<td class='cell${M[i][j]?' dep':''}' data-r='${i}' data-c='${j}'></td>`;}h+='</tr>';}
|
||||||
|
t.innerHTML=h;
|
||||||
|
t.addEventListener('mouseover',e=>{const c=e.target.closest('td.cell,th.col,th.row');clr();if(!c)return;const r=c.dataset.r,col=c.dataset.c;
|
||||||
|
if(r!=null){t.querySelectorAll(`td.cell[data-r='${r}']`).forEach(x=>x.classList.add('hlc'));t.querySelector(`tr[data-r='${r}']`)?.classList.add('hl');}
|
||||||
|
if(col!=null)t.querySelectorAll(`td[data-c='${col}']`).forEach(x=>x.classList.add('hlc'));});
|
||||||
|
function clr(){t.querySelectorAll('.hl').forEach(x=>x.classList.remove('hl'));t.querySelectorAll('.hlc').forEach(x=>x.classList.remove('hlc'));}
|
||||||
|
</script></body></html>";
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
|
||||||
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
|
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||||
|
<AssemblyName>DsmTool</AssemblyName>
|
||||||
|
<RootNamespace>DsmTool</RootNamespace>
|
||||||
|
|
||||||
|
<!-- C방향: 완전 무설치 · 초경량 단일 네이티브 exe -->
|
||||||
|
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||||
|
<PublishAot>true</PublishAot>
|
||||||
|
<InvariantGlobalization>true</InvariantGlobalization>
|
||||||
|
<StripSymbols>true</StripSymbols>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Avalonia" Version="11.2.3" />
|
||||||
|
<PackageReference Include="Avalonia.Desktop" Version="11.2.3" />
|
||||||
|
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.3" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- 네이티브 렌더 DLL을 exe에 임베드 → 실행 시 자체 추출(진짜 단일 exe) -->
|
||||||
|
<ItemGroup>
|
||||||
|
<EmbeddedResource Include="native\libSkiaSharp.dll" />
|
||||||
|
<EmbeddedResource Include="native\av_libglesv2.dll" />
|
||||||
|
<EmbeddedResource Include="native\libHarfBuzzSharp.dll" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
x:Class="DsmTool.MainWindow"
|
||||||
|
Width="760" Height="640" MinWidth="640" MinHeight="520"
|
||||||
|
Title="의존성 매트릭스 생성기 (DSM)"
|
||||||
|
WindowStartupLocation="CenterScreen">
|
||||||
|
|
||||||
|
<Grid Margin="16" RowDefinitions="Auto,Auto,Auto,Auto,Auto,*,Auto">
|
||||||
|
|
||||||
|
<TextBlock Grid.Row="0" Text="프로젝트 의존성 매트릭스 (DSM) → HTML/CSV 생성기"
|
||||||
|
FontSize="18" FontWeight="SemiBold" Margin="0,0,0,12"/>
|
||||||
|
|
||||||
|
<!-- Root -->
|
||||||
|
<Grid Grid.Row="1" ColumnDefinitions="90,*,Auto" Margin="0,0,0,8">
|
||||||
|
<TextBlock Grid.Column="0" Text="대상 폴더" VerticalAlignment="Center"/>
|
||||||
|
<TextBox Grid.Column="1" x:Name="RootBox" Watermark="분석할 저장소 루트 (*.csproj / *.vcxproj 검색)"/>
|
||||||
|
<Button Grid.Column="2" Content="찾기…" Margin="8,0,0,0" Click="BrowseRoot"/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Output -->
|
||||||
|
<Grid Grid.Row="2" ColumnDefinitions="90,*,Auto" Margin="0,0,0,8">
|
||||||
|
<TextBlock Grid.Column="0" Text="출력 폴더" VerticalAlignment="Center"/>
|
||||||
|
<TextBox Grid.Column="1" x:Name="OutBox" Watermark="비워두면 대상 폴더에 생성"/>
|
||||||
|
<Button Grid.Column="2" Content="찾기…" Margin="8,0,0,0" Click="BrowseOut"/>
|
||||||
|
</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">
|
||||||
|
<TextBlock Text="그룹:" VerticalAlignment="Center"/>
|
||||||
|
<ComboBox x:Name="GroupCombo" Width="130" SelectedIndex="0">
|
||||||
|
<ComboBoxItem>auto</ComboBoxItem>
|
||||||
|
<ComboBoxItem>topfolder</ComboBoxItem>
|
||||||
|
<ComboBoxItem>prefix</ComboBoxItem>
|
||||||
|
<ComboBoxItem>none</ComboBoxItem>
|
||||||
|
</ComboBox>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Expander Grid.Row="4" Header="고급 설정 (제외 경로 · 수동 패키지 매핑)" Margin="0,4,0,8">
|
||||||
|
<StackPanel Spacing="6" Margin="4">
|
||||||
|
<TextBlock Text="제외 경로 조각 (한 줄에 하나, 소문자 부분일치) — 빌드 산출물 등" Foreground="Gray"/>
|
||||||
|
<TextBox x:Name="ExcludeBox" AcceptsReturn="True" Height="100"
|
||||||
|
FontFamily="Consolas" TextWrapping="NoWrap"
|
||||||
|
ScrollViewer.HorizontalScrollBarVisibility="Auto" ScrollViewer.VerticalScrollBarVisibility="Auto"/>
|
||||||
|
<TextBlock Text="수동 패키지 매핑 (선택) pkgid=노드명 — 자동감지 보정용, 비워도 됨" Foreground="Gray" Margin="0,6,0,0"/>
|
||||||
|
<TextBox x:Name="ComponentBox" AcceptsReturn="True" Height="80"
|
||||||
|
FontFamily="Consolas" TextWrapping="NoWrap"
|
||||||
|
ScrollViewer.HorizontalScrollBarVisibility="Auto" ScrollViewer.VerticalScrollBarVisibility="Auto"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Expander>
|
||||||
|
|
||||||
|
<!-- Log -->
|
||||||
|
<Border Grid.Row="5" BorderBrush="#3A3A3A" BorderThickness="1" CornerRadius="4">
|
||||||
|
<TextBox x:Name="LogBox" IsReadOnly="True" AcceptsReturn="True"
|
||||||
|
FontFamily="Consolas" FontSize="12"
|
||||||
|
ScrollViewer.VerticalScrollBarVisibility="Auto"
|
||||||
|
Background="Transparent" BorderThickness="0"/>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Buttons -->
|
||||||
|
<StackPanel Grid.Row="6" Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8" Margin="0,12,0,0">
|
||||||
|
<Button x:Name="OpenFolderBtn" Content="출력 폴더 열기" Click="OpenFolder" IsEnabled="False"/>
|
||||||
|
<Button x:Name="OpenHtmlBtn" Content="HTML 브라우저로 열기" Click="OpenHtml" IsEnabled="False"/>
|
||||||
|
<Button x:Name="GenBtn" Content="생성" Click="Generate"
|
||||||
|
Background="#1F6FEB" Foreground="White" FontWeight="SemiBold" Padding="18,6"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
|
using Avalonia.Platform.Storage;
|
||||||
|
|
||||||
|
namespace DsmTool;
|
||||||
|
|
||||||
|
public partial class MainWindow : Window
|
||||||
|
{
|
||||||
|
private string? _lastHtml;
|
||||||
|
private string? _lastOut;
|
||||||
|
|
||||||
|
public MainWindow()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
|
||||||
|
// 범용 기본값 — 빌드 산출물만 제외, 내부 NuGet은 자동 감지
|
||||||
|
ExcludeBox.Text = string.Join(Environment.NewLine, DsmConfig.DefaultExcludePaths);
|
||||||
|
ComponentBox.Text = "";
|
||||||
|
|
||||||
|
// 실행 시 대상 폴더에 현재 폴더 자동 삽입
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cur = Directory.GetCurrentDirectory();
|
||||||
|
if (Directory.Exists(cur)) RootBox.Text = cur;
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
|
||||||
|
Log("대상 폴더가 현재 폴더로 지정되었습니다. 필요 시 변경 후 [생성] 을 누르세요.");
|
||||||
|
Log("팁: 대상 폴더에 dsm.config.ini 를 두면 팀별 설정이 자동 적용됩니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void BrowseRoot(object? s, Avalonia.Interactivity.RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var p = await PickFolder("분석할 대상 폴더 선택");
|
||||||
|
if (p != null) RootBox.Text = p;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void BrowseOut(object? s, Avalonia.Interactivity.RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var p = await PickFolder("출력 폴더 선택");
|
||||||
|
if (p != null) OutBox.Text = p;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string?> PickFolder(string title)
|
||||||
|
{
|
||||||
|
var top = GetTopLevel(this);
|
||||||
|
if (top is null) return null;
|
||||||
|
var res = await top.StorageProvider.OpenFolderPickerAsync(
|
||||||
|
new FolderPickerOpenOptions { Title = title, AllowMultiple = false });
|
||||||
|
return res.Count > 0 ? res[0].TryGetLocalPath() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void Generate(object? s, Avalonia.Interactivity.RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var root = (RootBox.Text ?? "").Trim().Trim('"');
|
||||||
|
if (string.IsNullOrWhiteSpace(root) || !Directory.Exists(root))
|
||||||
|
{
|
||||||
|
Log("⚠ 유효한 대상 폴더가 아닙니다: " + root);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var outDir = (OutBox.Text ?? "").Trim().Trim('"');
|
||||||
|
if (string.IsNullOrWhiteSpace(outDir)) outDir = root;
|
||||||
|
|
||||||
|
var cfg = new DsmConfig
|
||||||
|
{
|
||||||
|
ExcludeSamples = SamplesBox.IsChecked == true,
|
||||||
|
InternalAuto = AutoInternalBox.IsChecked == true,
|
||||||
|
GroupMode = (GroupCombo.SelectedItem as ContentControl)?.Content?.ToString() ?? "auto",
|
||||||
|
ExcludePaths = SplitLines(ExcludeBox.Text).Select(x => x.ToLowerInvariant()).ToList(),
|
||||||
|
ManualMap = ParseMap(SplitLines(ComponentBox.Text)),
|
||||||
|
};
|
||||||
|
if (cfg.ExcludePaths.Count == 0) cfg.ExcludePaths = DsmConfig.DefaultExcludePaths.ToList();
|
||||||
|
|
||||||
|
GenBtn.IsEnabled = false;
|
||||||
|
LogBox.Text = "";
|
||||||
|
Log("분석 시작… " + root);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var r = await Task.Run(() => DsmEngine.Run(root, outDir, cfg, Log));
|
||||||
|
_lastHtml = r.HtmlPath;
|
||||||
|
_lastOut = outDir;
|
||||||
|
OpenHtmlBtn.IsEnabled = true;
|
||||||
|
OpenFolderBtn.IsEnabled = true;
|
||||||
|
Log("");
|
||||||
|
Log($"✔ 완료 노드 {r.Nodes} · 내부 간선 {r.Edges} · 순환(상삼각) 위반 {r.Upper}");
|
||||||
|
Log($" 최다 Fan-in : {r.MaxFanIn} ({r.MaxFanInV})");
|
||||||
|
Log($" 최다 Fan-out: {r.MaxFanOut} ({r.MaxFanOutV})");
|
||||||
|
Log($" → {r.HtmlPath}");
|
||||||
|
Log($" → {r.CsvPath}");
|
||||||
|
if (r.Upper > 0) Log(" ⚠ 상삼각 위반 > 0 : 순환 의존이 존재합니다.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log("✘ 오류: " + ex.Message);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
GenBtn.IsEnabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenHtml(object? s, Avalonia.Interactivity.RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_lastHtml != null && File.Exists(_lastHtml))
|
||||||
|
Process.Start(new ProcessStartInfo { FileName = _lastHtml, UseShellExecute = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenFolder(object? s, Avalonia.Interactivity.RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_lastOut != null && Directory.Exists(_lastOut))
|
||||||
|
Process.Start(new ProcessStartInfo { FileName = _lastOut, UseShellExecute = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, string> ParseMap(IEnumerable<string> lines)
|
||||||
|
{
|
||||||
|
var d = new Dictionary<string, string>();
|
||||||
|
foreach (var line in lines)
|
||||||
|
{
|
||||||
|
var i = line.IndexOf('=');
|
||||||
|
if (i <= 0) continue;
|
||||||
|
var k = line[..i].Trim().ToLowerInvariant();
|
||||||
|
var v = line[(i + 1)..].Trim();
|
||||||
|
if (k.Length > 0 && v.Length > 0) d[k] = v;
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> SplitLines(string? text)
|
||||||
|
{
|
||||||
|
var list = new List<string>();
|
||||||
|
if (string.IsNullOrEmpty(text)) return list;
|
||||||
|
foreach (var raw in text.Replace("\r", "").Split('\n'))
|
||||||
|
{
|
||||||
|
var t = raw.Trim();
|
||||||
|
if (t.Length > 0) list.Add(t);
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Log(string msg)
|
||||||
|
{
|
||||||
|
if (Avalonia.Threading.Dispatcher.UIThread.CheckAccess())
|
||||||
|
LogBox.Text += msg + Environment.NewLine;
|
||||||
|
else
|
||||||
|
Avalonia.Threading.Dispatcher.UIThread.Post(() => LogBox.Text += msg + Environment.NewLine);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using Avalonia;
|
||||||
|
|
||||||
|
namespace DsmTool;
|
||||||
|
|
||||||
|
internal static class Program
|
||||||
|
{
|
||||||
|
[STAThread]
|
||||||
|
public static void Main(string[] args)
|
||||||
|
{
|
||||||
|
// 헤드리스 CLI 모드: DsmTool.exe --cli <대상폴더> [출력폴더]
|
||||||
|
if (args.Length >= 2 && args[0] == "--cli")
|
||||||
|
{
|
||||||
|
RunCli(args);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var crash = Path.Combine(Path.GetTempPath(), "DsmTool_crash.log");
|
||||||
|
try { File.WriteAllText(crash, "start\n"); } catch { }
|
||||||
|
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
|
||||||
|
{ try { File.AppendAllText(crash, "UNHANDLED: " + e.ExceptionObject + "\n"); } catch { } };
|
||||||
|
try
|
||||||
|
{
|
||||||
|
PrepareNativeLibs(); // 임베드된 네이티브 DLL 자체 추출 (진짜 단일 exe)
|
||||||
|
try { File.AppendAllText(crash, "natives ready\n"); } catch { }
|
||||||
|
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
try { File.AppendAllText(crash, "EX: " + ex + "\n"); } catch { }
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AppBuilder BuildAvaloniaApp() =>
|
||||||
|
AppBuilder.Configure<App>()
|
||||||
|
.UsePlatformDetect()
|
||||||
|
.LogToTrace();
|
||||||
|
|
||||||
|
private static void RunCli(string[] args)
|
||||||
|
{
|
||||||
|
var pos = args.Skip(1).Where(a => a != "--cli").ToList();
|
||||||
|
string? iniArg = null;
|
||||||
|
var ci = pos.FindIndex(a => a == "--config");
|
||||||
|
if (ci >= 0 && ci + 1 < pos.Count) { iniArg = pos[ci + 1]; pos.RemoveRange(ci, 2); }
|
||||||
|
|
||||||
|
var root = pos.Count >= 1 ? pos[0].Trim().Trim('"') : ".";
|
||||||
|
var outDir = pos.Count >= 2 ? pos[1].Trim().Trim('"') : root;
|
||||||
|
var log = new System.Text.StringBuilder();
|
||||||
|
void L(string m) { log.AppendLine(m); try { Console.WriteLine(m); } catch { } }
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var ini = iniArg ?? FindConfig(root);
|
||||||
|
if (ini != null) L($"config: {ini}");
|
||||||
|
var cfg = DsmConfig.Load(ini);
|
||||||
|
var r = DsmEngine.Run(root, outDir, cfg, L);
|
||||||
|
L($"OK nodes={r.Nodes} edges={r.Edges} upper={r.Upper}");
|
||||||
|
L($"maxFanIn={r.MaxFanIn}({r.MaxFanInV}) maxFanOut={r.MaxFanOut}({r.MaxFanOutV})");
|
||||||
|
L($"-> {r.HtmlPath}");
|
||||||
|
L($"-> {r.CsvPath}");
|
||||||
|
try { File.WriteAllText(Path.Combine(outDir, "_cli_result.txt"), log.ToString()); } catch { }
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
try { File.WriteAllText(Path.Combine(outDir, "_cli_result.txt"), log + "\nERROR: " + ex); } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dsm.config.ini 자동 탐색: 대상폴더 → exe 폴더
|
||||||
|
internal static string? FindConfig(string root)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var a = Path.Combine(root, "dsm.config.ini");
|
||||||
|
if (File.Exists(a)) return a;
|
||||||
|
var exeDir = AppContext.BaseDirectory;
|
||||||
|
var b = Path.Combine(exeDir, "dsm.config.ini");
|
||||||
|
if (File.Exists(b)) return b;
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 임베드된 네이티브 DLL 추출 후 검색경로 등록 (단일 exe 자체 실행) ----
|
||||||
|
private static void PrepareNativeLibs()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var asm = Assembly.GetExecutingAssembly();
|
||||||
|
var names = asm.GetManifestResourceNames()
|
||||||
|
.Where(n => n.Contains(".native.") && n.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToArray();
|
||||||
|
if (names.Length == 0) return; // dev 빌드(임베드 없음) → 옆의 loose dll 사용
|
||||||
|
|
||||||
|
var dir = Path.Combine(Path.GetTempPath(), "DsmTool_native");
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
foreach (var res in names)
|
||||||
|
{
|
||||||
|
var file = res[(res.IndexOf(".native.", StringComparison.Ordinal) + ".native.".Length)..];
|
||||||
|
var dest = Path.Combine(dir, file);
|
||||||
|
using var s = asm.GetManifestResourceStream(res);
|
||||||
|
if (s == null) continue;
|
||||||
|
if (!File.Exists(dest) || new FileInfo(dest).Length != s.Length)
|
||||||
|
{
|
||||||
|
using var fs = File.Create(dest);
|
||||||
|
s.CopyTo(fs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SetDllDirectory(dir);
|
||||||
|
}
|
||||||
|
catch { /* 실패 시 loose dll 로 폴백 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
[DllImport("kernel32", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||||
|
private static extern bool SetDllDirectory(string lpPathName);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# DsmTool — 의존성 매트릭스 생성기 (범용 단일 exe)
|
||||||
|
|
||||||
|
임의의 `.NET/C++` 저장소를 스캔해 **프로젝트 의존성 매트릭스(DSM)** 를 `HTML + CSV` 로 만드는 GUI/CLI 도구.
|
||||||
|
**NativeAOT 단일 exe(완전 무설치·약 31MB)** 로 배포된다. 여러 팀의 저장소에 **설정만 바꿔** 재사용할 수 있다.
|
||||||
|
|
||||||
|
## 배포물
|
||||||
|
- **`..\DsmTool.exe`** — 이것 하나만 복사하면 실행. .NET 설치/웹뷰 불필요.
|
||||||
|
(Avalonia 네이티브 렌더 DLL 3종은 exe 안에 임베드 → 최초 실행 시 `%TEMP%\DsmTool_native` 로 자체 추출)
|
||||||
|
|
||||||
|
## 동작 원리 (범용)
|
||||||
|
각 `*.csproj / *.vcxproj` 에서 **내부 → 내부** 의존만 추출한다:
|
||||||
|
1. **ProjectReference** → 대상 프로젝트로 간선
|
||||||
|
2. **`<Reference>`(HintPath)** → 어셈블리명이 스캔된 프로젝트명과 일치하면 간선
|
||||||
|
3. **PackageReference** → **자동 감지**: id 가 스캔된 프로젝트명 또는 `<PackageId>` 와 일치하면 내부 간선
|
||||||
|
(버전 접미사 등으로 모호하면 링크 생략 → 필요 시 `map=` 로 수동 보정)
|
||||||
|
|
||||||
|
노드는 위상 정렬(공급자 먼저)되며, 순환이 없으면 하삼각 행렬이 된다(상삼각 위반 0 = 비순환 = 빌드 순서).
|
||||||
|
동일 파일명이 여러 개면 상대경로로 자동 구분한다.
|
||||||
|
|
||||||
|
## 사용법 (GUI)
|
||||||
|
1. `DsmTool.exe` 실행 → **대상 폴더** 선택(필요 시 **출력 폴더**).
|
||||||
|
2. 옵션: *Sample 제외*, *내부 NuGet 자동 감지*, **그룹 모드**(auto/topfolder/prefix/none).
|
||||||
|
*고급 설정* 에서 제외 경로·수동 패키지 매핑 조정.
|
||||||
|
3. **생성** → `dependency_matrix.html`, `dependency_matrix.csv`.
|
||||||
|
4. **HTML 브라우저로 열기**(내장 웹뷰 없음 — 기본 브라우저).
|
||||||
|
|
||||||
|
## 사용법 (CLI)
|
||||||
|
```
|
||||||
|
DsmTool.exe --cli <대상폴더> [출력폴더] [--config <ini경로>]
|
||||||
|
```
|
||||||
|
요약은 `<출력폴더>\_cli_result.txt` 에도 기록(자동화/CI 용).
|
||||||
|
|
||||||
|
## 팀별 설정 — `dsm.config.ini` (재빌드 불필요)
|
||||||
|
대상 폴더(또는 exe 폴더)에 `dsm.config.ini` 를 두면 자동 적용된다. 예시는 [dsm.config.sample.ini](dsm.config.sample.ini).
|
||||||
|
```ini
|
||||||
|
exclude = \source\engine\ ; 거대 서브트리를 블랙박스로 제외(반복 가능, 기본에 추가)
|
||||||
|
excludeSamples = true
|
||||||
|
internal = auto ; 내부 NuGet 자동 감지 (none 으로 끄기)
|
||||||
|
group = auto ; auto | topfolder | prefix | none
|
||||||
|
map = MyPkg_1.2 = MyProject ; 버전 모호 패키지 수동 매핑(선택)
|
||||||
|
```
|
||||||
|
기본 제외(빌드 산출물): `\bin\ \obj\ \.vs\ \.git\ _wpftmp \packages\ \node_modules\ \testresults\ \_backup\ \.old\`
|
||||||
|
|
||||||
|
## 검증된 예 (기준값)
|
||||||
|
| 대상 | 설정 | 결과 |
|
||||||
|
|------|------|------|
|
||||||
|
| `D:\ZOO` | `zoo.dsm.config.ini`(engine/oda/bcg 제외) | 68 노드 · 80 간선 · 순환 0 |
|
||||||
|
| `E:\hanmac\EG-BIM_Modeler` | 기본값(그룹 auto→prefix) | 244 노드 · 600 간선 · 순환 0 |
|
||||||
|
|
||||||
|
> 참고: ZOO 의 `TeighaCodePack_22.12_16/_23.1_16` 처럼 **버전 접미사 내부 패키지**는 자동 매칭이 모호해 생략된다. 정확히 잇고 싶으면 `dsm.config.ini` 에 `map=` 로 지정.
|
||||||
|
|
||||||
|
## 다시 빌드
|
||||||
|
> ⚠ NativeAOT 링커는 **한글/비ASCII 경로에서 실패**한다. 아래 스크립트가 소스를 ASCII 임시경로로 복사해 빌드 후 exe만 회수한다.
|
||||||
|
```
|
||||||
|
build-single-exe.bat
|
||||||
|
```
|
||||||
|
요구: .NET 8/9 SDK + Visual Studio 2022 **“C++ 데스크톱 개발”** 워크로드. (실행 자체는 한글 경로 무관)
|
||||||
|
|
||||||
|
## 구성 파일
|
||||||
|
| 파일 | 역할 |
|
||||||
|
|------|------|
|
||||||
|
| `Program.cs` | 진입점 · CLI · 설정 자동탐색 · 네이티브 DLL 자체추출 |
|
||||||
|
| `DsmEngine.cs` | 핵심(파싱→DSM→HTML/CSV) · `DsmConfig`(설정/ini 로더) |
|
||||||
|
| `MainWindow.axaml(.cs)` / `App.axaml(.cs)` | GUI (Avalonia) |
|
||||||
|
| `native\*.dll` | 임베드용 Avalonia 네이티브 렌더 DLL |
|
||||||
|
| `dsm.config.sample.ini` | 설정 예시 |
|
||||||
|
| `build-single-exe.bat` | 단일 exe 빌드(ASCII 경로 우회 포함) |
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||||
|
<assemblyIdentity version="1.0.0.0" name="DsmTool" />
|
||||||
|
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<windowsSettings>
|
||||||
|
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||||
|
</windowsSettings>
|
||||||
|
</application>
|
||||||
|
</assembly>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal enabledelayedexpansion
|
||||||
|
chcp 65001 >nul
|
||||||
|
REM == DsmTool single-exe build (NativeAOT) ==
|
||||||
|
REM NativeAOT linker fails on non-ASCII paths, so copy sources to an ASCII temp
|
||||||
|
REM dir, build there, then copy the exe back. Requires: .NET SDK + VS C++ workload.
|
||||||
|
|
||||||
|
set "PROJ=%~dp0"
|
||||||
|
set "WORK=%TEMP%\dsmbuild_ascii"
|
||||||
|
|
||||||
|
echo [1/4] copy sources to %WORK%
|
||||||
|
if exist "%WORK%" rmdir /s /q "%WORK%"
|
||||||
|
mkdir "%WORK%"
|
||||||
|
xcopy "%PROJ%*.cs" "%WORK%\" /y >nul
|
||||||
|
xcopy "%PROJ%*.csproj" "%WORK%\" /y >nul
|
||||||
|
xcopy "%PROJ%*.axaml" "%WORK%\" /y >nul
|
||||||
|
xcopy "%PROJ%*.manifest" "%WORK%\" /y >nul
|
||||||
|
xcopy "%PROJ%native\*" "%WORK%\native\" /y /i >nul
|
||||||
|
|
||||||
|
echo [2/4] locate Visual C++ (vcvars64)
|
||||||
|
set "VCV="
|
||||||
|
for %%E in (Enterprise Professional Community BuildTools) do (
|
||||||
|
if not defined VCV if exist "C:\Program Files\Microsoft Visual Studio\2022\%%E\VC\Auxiliary\Build\vcvars64.bat" set "VCV=C:\Program Files\Microsoft Visual Studio\2022\%%E\VC\Auxiliary\Build\vcvars64.bat"
|
||||||
|
)
|
||||||
|
for %%E in (Enterprise Professional Community BuildTools) do (
|
||||||
|
if not defined VCV if exist "C:\Program Files (x86)\Microsoft Visual Studio\2022\%%E\VC\Auxiliary\Build\vcvars64.bat" set "VCV=C:\Program Files (x86)\Microsoft Visual Studio\2022\%%E\VC\Auxiliary\Build\vcvars64.bat"
|
||||||
|
)
|
||||||
|
if not defined VCV (
|
||||||
|
echo [ERROR] vcvars64.bat not found. Install VS 2022 "Desktop development with C++".
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
set "PATH=%PATH%;C:\Program Files (x86)\Microsoft Visual Studio\Installer"
|
||||||
|
call "%VCV%" >nul
|
||||||
|
|
||||||
|
echo [3/4] publish NativeAOT single exe
|
||||||
|
pushd "%WORK%"
|
||||||
|
dotnet publish -c Release
|
||||||
|
set "ERR=%ERRORLEVEL%"
|
||||||
|
popd
|
||||||
|
if not "%ERR%"=="0" ( echo [ERROR] publish failed %ERR% & exit /b %ERR% )
|
||||||
|
|
||||||
|
echo [4/4] copy exe back
|
||||||
|
copy /y "%WORK%\bin\x64\Release\net9.0\win-x64\publish\DsmTool.exe" "%PROJ%..\DsmTool.exe" >nul
|
||||||
|
echo DONE: "%PROJ%..\DsmTool.exe"
|
||||||
|
endlocal
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# ================================================================
|
||||||
|
# DsmTool 설정 예시 (dsm.config.ini 로 저장해 사용)
|
||||||
|
# 자동 탐색 순서: 대상폴더\dsm.config.ini → exe폴더\dsm.config.ini
|
||||||
|
# 또는 DsmTool.exe --cli <root> <out> --config <경로>
|
||||||
|
# 형식: key = value (# 또는 ; 는 주석, 일부 key 는 반복 가능)
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
|
# --- 제외 경로 (소문자 부분일치) : 기본(빌드 산출물)에 '추가'됨 -------
|
||||||
|
# 기본 제외: \bin\ \obj\ \.vs\ \.git\ _wpftmp \packages\ \node_modules\ \testresults\ \_backup\ \.old\
|
||||||
|
# exclude = \source\engine\
|
||||||
|
# exclude = \library\oda\
|
||||||
|
# exclude = bcgcontrolbarpro
|
||||||
|
# 기본 제외를 무시하고 아래 exclude 만 쓰려면:
|
||||||
|
# excludeReset = true
|
||||||
|
|
||||||
|
# --- 샘플/테스트 제외 -------------------------------------------------
|
||||||
|
excludeSamples = true
|
||||||
|
# 샘플 판정 substring(반복). 지정 시 기본 마커를 대체.
|
||||||
|
# 기본 마커: sample example demo .test \test 테스트 쓰레기통 temp
|
||||||
|
# samplemarker = sample
|
||||||
|
# samplemarker = \test
|
||||||
|
|
||||||
|
# --- 내부 NuGet 자동 감지 --------------------------------------------
|
||||||
|
# PackageReference id 가 스캔된 프로젝트명(또는 <PackageId>)과 일치하면 내부 간선으로.
|
||||||
|
# 끄려면: internal = none
|
||||||
|
internal = auto
|
||||||
|
# 버전 접미사가 붙은 내부 패키지처럼 자동 매칭이 모호한 경우 수동 보정:
|
||||||
|
# map = TeighaCodePack_22.12_16 = TeighaCodePack (Component/TeighaCodePack/Ver_22.12_16)
|
||||||
|
# map = MyInternalPkg = MyProject
|
||||||
|
|
||||||
|
# --- 그룹(색상) 모드 -------------------------------------------------
|
||||||
|
# auto : 최상위폴더로 묶되, 평면 배치면 이름 접두어로 자동 전환
|
||||||
|
# topfolder : 루트 바로 아래 폴더
|
||||||
|
# prefix : 프로젝트명 접두어(Eg, Editor ...)
|
||||||
|
# none : 단일 그룹
|
||||||
|
group = auto
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,9 @@
|
|||||||
|
@echo off
|
||||||
|
cd /d "%~dp0"
|
||||||
|
set "PATH=%PATH%;C:\Program Files (x86)\Microsoft Visual Studio\Installer"
|
||||||
|
call "C:\Program Files\Microsoft Visual Studio\2022\Professional\VC\Auxiliary\Build\vcvars64.bat"
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo [ERROR] vcvars64.bat not found. Install "Desktop development with C++" workload.
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
dotnet publish -c Release
|
||||||
Reference in New Issue
Block a user