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:
2026-07-08 11:04:56 +09:00
co-authored by Claude Opus 4.8
commit 8250efbe57
17 changed files with 976 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
# 로컬 설정/시크릿
configs/
.mcp.json
# .NET 빌드 산출물
bin/
obj/
*.user
# 기타
*.log
Thumbs.db
BIN
View File
Binary file not shown.
+8
View File
@@ -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>
+18
View File
@@ -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();
}
}
+393
View File
@@ -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("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");
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,'&lt;')}</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,'&lt;')}<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>";
}
+33
View File
@@ -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>
+72
View File
@@ -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>
+153
View File
@@ -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);
}
}
+121
View File
@@ -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);
}
+67
View File
@@ -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 경로 우회 포함) |
+9
View File
@@ -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>
+45
View File
@@ -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
+36
View File
@@ -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.
+9
View File
@@ -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