- SqlAnalysisService에 script intent, dependency, review focus 계산을 추가해 migration/seed/reporting SQL의 위험도와 검토 포인트를 더 정확히 안내하도록 개선했습니다. - HtmlSkill에 decision_matrix, metric_strip 섹션을 추가하고 ArtifactQualityReviewService/ArtifactRepairGuideService에서 board·strategy 문서의 의사결정 구조와 KPI 연결 부족을 더 정밀하게 진단하도록 강화했습니다. - DeckQualityReviewService와 DeckRepairGuideService를 확장해 executive summary headline, comparison trade-off, roadmap milestone, chart takeaway, KPI context 부족을 추가로 감지하고 보정 가이드를 반환하도록 정리했습니다. - WorkspaceContextGenerator와 CodeLanguageCatalog를 업데이트해 SQL 저장소에서 SQL Review Focus와 확장된 workflow summary를 제공하도록 맞췄고, README/DEVELOPMENT/NEXT_ROADMAP에 2026-04-15 11:36 (KST) 기준 이력을 반영했습니다. 검증 결과 - dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_code_sql_doc_final\\ -p:IntermediateOutputPath=obj\\verify_code_sql_doc_final\\ : 경고 0 / 오류 0 - dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "SqlDialectDetectorTests|SqlAnalysisServiceTests|CodeLanguageCatalogTests|WorkspaceContextGeneratorTests|ArtifactQualityReviewServiceTests|ArtifactRepairGuideServiceTests|DeckQualityReviewServiceTests|HtmlSkillConsultingSectionsTests" -p:OutputPath=bin\\verify_code_sql_doc_final_tests\\ -p:IntermediateOutputPath=obj\\verify_code_sql_doc_final_tests\\ : 통과 62
560 lines
20 KiB
C#
560 lines
20 KiB
C#
using System.Diagnostics;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Text;
|
|
using System.Text.RegularExpressions;
|
|
|
|
namespace AxCopilot.Services.Agent;
|
|
|
|
/// <summary>
|
|
/// 작업 폴더의 구조/기술스택/컨벤션을 분석하여 .ax-context.md를 자동 생성합니다.
|
|
/// LLM 호출 없이 순수 파일 시스템 분석으로 동작합니다.
|
|
/// </summary>
|
|
internal static class WorkspaceContextGenerator
|
|
{
|
|
private const string ContextFileName = ".ax-context.md";
|
|
private const int MaxDepth = 3;
|
|
private const int MaxReadmeChars = 2000;
|
|
private const int MaxContextChars = 4000;
|
|
|
|
private static readonly HashSet<string> SkipDirs = new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
".git", "node_modules", "bin", "obj", ".vs", "__pycache__", ".idea",
|
|
".vscode", "dist", "build", "target", ".next", ".nuget", "packages",
|
|
".ax", "coverage", ".mypy_cache", "venv", ".venv", "env",
|
|
};
|
|
|
|
/// <summary>
|
|
/// .ax-context.md가 없으면 생성합니다. 이미 있으면 기존 내용을 반환합니다.
|
|
/// </summary>
|
|
public static async Task<string?> EnsureContextAsync(string workFolder, CancellationToken ct = default)
|
|
{
|
|
if (string.IsNullOrEmpty(workFolder) || !Directory.Exists(workFolder))
|
|
return null;
|
|
|
|
var path = Path.Combine(workFolder, ContextFileName);
|
|
if (File.Exists(path))
|
|
return LoadContext(workFolder);
|
|
|
|
return await GenerateAsync(workFolder, ct).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 강제 재생성합니다.
|
|
/// </summary>
|
|
public static async Task<string> GenerateAsync(string workFolder, CancellationToken ct = default)
|
|
{
|
|
var sb = new StringBuilder();
|
|
sb.AppendLine("# Workspace Context (auto-generated)");
|
|
sb.AppendLine($"Generated: {DateTime.Now:yyyy-MM-dd}");
|
|
sb.AppendLine();
|
|
|
|
// 1. 프로젝트 기본 정보
|
|
var buildSystem = DetectBuildSystem(workFolder);
|
|
var extDist = GetExtensionDistribution(workFolder, ct);
|
|
var primaryLang = extDist.FirstOrDefault();
|
|
|
|
sb.AppendLine("## Project");
|
|
var projectName = Path.GetFileName(workFolder);
|
|
sb.AppendLine($"- Name: {projectName}");
|
|
if (buildSystem != null)
|
|
sb.AppendLine($"- Build System: {buildSystem}");
|
|
if (primaryLang.Key != null)
|
|
sb.AppendLine($"- Primary Language: {GetLanguageName(primaryLang.Key)} ({primaryLang.Key}: {primaryLang.Value} files)");
|
|
|
|
// Git 정보
|
|
var gitInfo = await GetGitInfoAsync(workFolder, ct).ConfigureAwait(false);
|
|
if (gitInfo.Branch != null)
|
|
sb.AppendLine($"- Git Branch: {gitInfo.Branch}");
|
|
if (gitInfo.Remote != null)
|
|
sb.AppendLine($"- Git Remote: {gitInfo.Remote}");
|
|
sb.AppendLine();
|
|
|
|
// 2. 디렉토리 구조
|
|
sb.AppendLine("## Structure");
|
|
var tree = BuildDirectoryTree(workFolder, MaxDepth);
|
|
foreach (var line in tree.Take(30)) // 최대 30줄
|
|
sb.AppendLine(line);
|
|
sb.AppendLine();
|
|
|
|
// 3. 확장자 분포
|
|
if (extDist.Count > 0)
|
|
{
|
|
sb.AppendLine("## File Distribution");
|
|
sb.AppendLine(string.Join(", ", extDist.Take(10).Select(kv => $"{kv.Key}: {kv.Value}")));
|
|
sb.AppendLine();
|
|
}
|
|
|
|
var languageSnapshot = BuildLanguageSnapshot(extDist);
|
|
if (languageSnapshot.Count > 0)
|
|
{
|
|
sb.AppendLine("## Language Snapshot");
|
|
foreach (var line in languageSnapshot)
|
|
sb.AppendLine($"- {line}");
|
|
sb.AppendLine();
|
|
}
|
|
|
|
// 4. 기존 컨텍스트 파일 감지
|
|
var workflowGuidance = BuildLanguageWorkflow(extDist);
|
|
if (workflowGuidance.Count > 0)
|
|
{
|
|
sb.AppendLine("## Language Workflow");
|
|
foreach (var line in workflowGuidance)
|
|
sb.AppendLine($"- {line}");
|
|
sb.AppendLine();
|
|
}
|
|
|
|
if (string.Equals(primaryLang.Key, ".sql", StringComparison.OrdinalIgnoreCase) ||
|
|
extDist.Any(entry => string.Equals(entry.Key, ".sql", StringComparison.OrdinalIgnoreCase) && entry.Value >= 3))
|
|
{
|
|
sb.AppendLine("## SQL Review Focus");
|
|
sb.AppendLine("- Classify each script as migration, seed, reporting query, or operational patch before execution.");
|
|
sb.AppendLine("- Review destructive DDL, broad UPDATE/DELETE statements, transaction scope, and dependency order.");
|
|
sb.AppendLine("- Validate scripts in a disposable database and capture rollback notes for production changes.");
|
|
sb.AppendLine();
|
|
}
|
|
|
|
var contextFiles = DetectContextFiles(workFolder);
|
|
if (contextFiles.Count > 0)
|
|
{
|
|
sb.AppendLine("## Existing Context Files");
|
|
foreach (var cf in contextFiles)
|
|
sb.AppendLine($"- {cf}");
|
|
sb.AppendLine();
|
|
}
|
|
|
|
// 5. README 요약
|
|
var agentContextSummary = DetectAgentContextSummary(workFolder);
|
|
if (agentContextSummary.Count > 0)
|
|
{
|
|
sb.AppendLine("## Agent Context");
|
|
foreach (var line in agentContextSummary)
|
|
sb.AppendLine($"- {line}");
|
|
sb.AppendLine();
|
|
}
|
|
|
|
var keyManifests = DetectKeyManifests(workFolder);
|
|
if (keyManifests.Count > 0)
|
|
{
|
|
sb.AppendLine("## Key Manifests");
|
|
foreach (var line in keyManifests)
|
|
sb.AppendLine($"- {line}");
|
|
sb.AppendLine();
|
|
}
|
|
|
|
var readmeSummary = ExtractReadmeSummary(workFolder);
|
|
if (readmeSummary != null)
|
|
{
|
|
sb.AppendLine("## README Summary");
|
|
sb.AppendLine(readmeSummary);
|
|
sb.AppendLine();
|
|
}
|
|
|
|
var content = sb.ToString().TrimEnd();
|
|
|
|
// 파일 저장
|
|
try
|
|
{
|
|
var path = Path.Combine(workFolder, ContextFileName);
|
|
await File.WriteAllTextAsync(path, content, ct).ConfigureAwait(false);
|
|
}
|
|
catch
|
|
{
|
|
// 저장 실패는 무시 — 읽기 전용 폴더 등
|
|
}
|
|
|
|
return content;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 기존 .ax-context.md를 읽습니다. 없으면 null.
|
|
/// </summary>
|
|
public static string? LoadContext(string? workFolder)
|
|
{
|
|
if (string.IsNullOrEmpty(workFolder)) return null;
|
|
var path = Path.Combine(workFolder, ContextFileName);
|
|
if (!File.Exists(path)) return null;
|
|
|
|
try
|
|
{
|
|
var content = File.ReadAllText(path);
|
|
return content.Length > MaxContextChars
|
|
? content[..MaxContextChars] + "\n...(truncated)"
|
|
: content;
|
|
}
|
|
catch { return null; }
|
|
}
|
|
|
|
internal static IReadOnlyList<string> DetectLanguageWorkflowHints(
|
|
string? workFolder,
|
|
string? preferredLanguage = null,
|
|
int maxLanguages = 3)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(workFolder) || !Directory.Exists(workFolder))
|
|
return [];
|
|
|
|
var extDist = GetExtensionDistribution(workFolder, CancellationToken.None);
|
|
return CodeLanguageCatalog.BuildWorkspaceWorkflowSummaries(
|
|
extDist.Select(x => x.Key),
|
|
preferredLanguage,
|
|
maxLanguages);
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════
|
|
// 분석 로직
|
|
// ════════════════════════════════════════════════════════════
|
|
|
|
private static string? DetectBuildSystem(string folder)
|
|
{
|
|
var checks = new (string Pattern, string Name)[]
|
|
{
|
|
("*.sln", ".NET (Solution)"),
|
|
("*.csproj", ".NET"),
|
|
("package.json", "Node.js"),
|
|
("Cargo.toml", "Rust"),
|
|
("go.mod", "Go"),
|
|
("pom.xml", "Java (Maven)"),
|
|
("build.gradle", "Java (Gradle)"),
|
|
("pyproject.toml", "Python"),
|
|
("requirements.txt", "Python"),
|
|
("Makefile", "Make"),
|
|
("CMakeLists.txt", "CMake"),
|
|
};
|
|
|
|
foreach (var (pattern, name) in checks)
|
|
{
|
|
try
|
|
{
|
|
if (Directory.GetFiles(folder, pattern, SearchOption.TopDirectoryOnly).Length > 0)
|
|
return name;
|
|
}
|
|
catch { /* 무시 */ }
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static List<KeyValuePair<string, int>> GetExtensionDistribution(
|
|
string folder, CancellationToken ct)
|
|
{
|
|
var counts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
try
|
|
{
|
|
foreach (var file in Directory.EnumerateFiles(folder, "*", new EnumerationOptions
|
|
{
|
|
RecurseSubdirectories = true,
|
|
IgnoreInaccessible = true,
|
|
MaxRecursionDepth = 5,
|
|
}))
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
|
|
// 경로 세그먼트 단위로 SkipDirs 검사 (정확한 디렉토리명 매칭)
|
|
var dir = Path.GetDirectoryName(file) ?? "";
|
|
if (ShouldSkipPath(dir))
|
|
continue;
|
|
|
|
var ext = Path.GetExtension(file);
|
|
if (string.IsNullOrEmpty(ext) || ext.Length > 8) continue;
|
|
|
|
counts.TryGetValue(ext, out var count);
|
|
counts[ext] = count + 1;
|
|
}
|
|
}
|
|
catch (OperationCanceledException) { throw; }
|
|
catch { /* 무시 */ }
|
|
|
|
return counts.OrderByDescending(kv => kv.Value).ToList();
|
|
}
|
|
|
|
private static List<string> BuildDirectoryTree(string root, int maxDepth)
|
|
{
|
|
var result = new List<string>();
|
|
BuildTreeRecursive(root, root, 0, maxDepth, result);
|
|
return result;
|
|
}
|
|
|
|
private static void BuildTreeRecursive(string root, string current, int depth, int maxDepth, List<string> result)
|
|
{
|
|
if (depth >= maxDepth || result.Count >= 30) return;
|
|
|
|
IEnumerable<string> dirs;
|
|
try { dirs = Directory.GetDirectories(current); }
|
|
catch { return; }
|
|
|
|
foreach (var dir in dirs.OrderBy(d => d))
|
|
{
|
|
if (result.Count >= 30) break;
|
|
|
|
var name = Path.GetFileName(dir);
|
|
if (SkipDirs.Contains(name)) continue;
|
|
|
|
// 심링크/junction 무한루프 방지: 속성 검사
|
|
try
|
|
{
|
|
var attrs = File.GetAttributes(dir);
|
|
if (attrs.HasFlag(FileAttributes.ReparsePoint))
|
|
continue;
|
|
}
|
|
catch { continue; }
|
|
|
|
// TopDirectoryOnly로 제한하여 대규모 디렉토리 탐색 방지
|
|
int fileCount;
|
|
try
|
|
{
|
|
fileCount = Directory.EnumerateFiles(dir, "*", new EnumerationOptions
|
|
{
|
|
RecurseSubdirectories = true,
|
|
IgnoreInaccessible = true,
|
|
MaxRecursionDepth = 3,
|
|
}).Take(10_000).Count(); // 최대 10K개만 카운트
|
|
}
|
|
catch { fileCount = 0; }
|
|
|
|
var indent = new string(' ', depth * 2);
|
|
var relativePath = Path.GetRelativePath(root, dir).Replace('\\', '/');
|
|
result.Add($"{indent}{relativePath}/ ({fileCount}{(fileCount >= 10_000 ? "+" : "")} files)");
|
|
|
|
BuildTreeRecursive(root, dir, depth + 1, maxDepth, result);
|
|
}
|
|
}
|
|
|
|
private static string? ExtractReadmeSummary(string folder)
|
|
{
|
|
var names = new[] { "README.md", "readme.md", "README", "README.txt" };
|
|
foreach (var name in names)
|
|
{
|
|
var path = Path.Combine(folder, name);
|
|
if (!File.Exists(path)) continue;
|
|
|
|
try
|
|
{
|
|
var text = File.ReadAllText(path);
|
|
if (text.Length > MaxReadmeChars)
|
|
text = text[..MaxReadmeChars];
|
|
|
|
// 첫 번째 단락 추출 (제목 제외)
|
|
var lines = text.Split('\n');
|
|
var paragraphLines = new List<string>();
|
|
var foundContent = false;
|
|
|
|
foreach (var line in lines)
|
|
{
|
|
var trimmed = line.Trim();
|
|
if (trimmed.StartsWith('#') && !foundContent) continue; // 제목 건너뛰기
|
|
if (string.IsNullOrWhiteSpace(trimmed))
|
|
{
|
|
if (foundContent && paragraphLines.Count > 0) break;
|
|
continue;
|
|
}
|
|
foundContent = true;
|
|
paragraphLines.Add(trimmed);
|
|
}
|
|
|
|
if (paragraphLines.Count > 0)
|
|
return string.Join(" ", paragraphLines);
|
|
}
|
|
catch { /* 무시 */ }
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static List<string> DetectContextFiles(string folder)
|
|
{
|
|
var files = new List<string>();
|
|
var names = new[] { "AGENTS.md", "AX.md", "CLAUDE.md", ".clinerules", ".ax-rules" };
|
|
foreach (var name in names)
|
|
{
|
|
if (File.Exists(Path.Combine(folder, name)))
|
|
files.Add(name);
|
|
}
|
|
|
|
var axDir = Path.Combine(folder, ".ax");
|
|
if (Directory.Exists(axDir))
|
|
{
|
|
try
|
|
{
|
|
var rulesDir = Path.Combine(axDir, "rules");
|
|
if (Directory.Exists(rulesDir))
|
|
{
|
|
var ruleFiles = Directory.GetFiles(rulesDir, "*.md");
|
|
if (ruleFiles.Length > 0)
|
|
files.Add($".ax/rules/ ({ruleFiles.Length} files)");
|
|
}
|
|
}
|
|
catch { /* 무시 */ }
|
|
}
|
|
|
|
return files;
|
|
}
|
|
|
|
private static List<string> DetectAgentContextSummary(string folder)
|
|
{
|
|
var lines = new List<string>();
|
|
|
|
try
|
|
{
|
|
var claudeSkillsDir = Path.Combine(folder, ".claude", "skills");
|
|
if (Directory.Exists(claudeSkillsDir))
|
|
{
|
|
var skillFiles = Directory.GetFiles(claudeSkillsDir, "SKILL.md", SearchOption.AllDirectories);
|
|
if (skillFiles.Length > 0)
|
|
lines.Add($".claude/skills 호환 스킬 {skillFiles.Length}개 감지");
|
|
}
|
|
}
|
|
catch { }
|
|
|
|
try
|
|
{
|
|
var axRulesDir = Path.Combine(folder, ".ax", "rules");
|
|
if (Directory.Exists(axRulesDir))
|
|
{
|
|
var ruleFiles = Directory.GetFiles(axRulesDir, "*.md", SearchOption.TopDirectoryOnly);
|
|
if (ruleFiles.Length > 0)
|
|
lines.Add($".ax/rules 규칙 {ruleFiles.Length}개 감지");
|
|
}
|
|
}
|
|
catch { }
|
|
|
|
try
|
|
{
|
|
var memoryFile = Path.Combine(folder, "AXMEMORY.md");
|
|
if (File.Exists(memoryFile))
|
|
lines.Add("AXMEMORY.md 메모리 파일 감지");
|
|
}
|
|
catch { }
|
|
|
|
return lines;
|
|
}
|
|
|
|
private static List<string> DetectKeyManifests(string folder)
|
|
{
|
|
var manifests = new List<string>();
|
|
var patterns = new (string Pattern, string Label)[]
|
|
{
|
|
("*.sln", "Solution"),
|
|
("*.csproj", ".NET project"),
|
|
("package.json", "Node package"),
|
|
("pyproject.toml", "Python project"),
|
|
("requirements.txt", "Python requirements"),
|
|
("Cargo.toml", "Rust package"),
|
|
("go.mod", "Go module"),
|
|
("pom.xml", "Maven project"),
|
|
("build.gradle", "Gradle build"),
|
|
};
|
|
|
|
foreach (var (pattern, label) in patterns)
|
|
{
|
|
try
|
|
{
|
|
var matches = Directory.GetFiles(folder, pattern, SearchOption.TopDirectoryOnly)
|
|
.Select(Path.GetFileName)
|
|
.Where(name => !string.IsNullOrWhiteSpace(name))
|
|
.Cast<string>()
|
|
.Take(3)
|
|
.ToList();
|
|
if (matches.Count > 0)
|
|
manifests.Add($"{label}: {string.Join(", ", matches)}");
|
|
}
|
|
catch { }
|
|
}
|
|
|
|
return manifests;
|
|
}
|
|
|
|
private static List<string> BuildLanguageSnapshot(List<KeyValuePair<string, int>> extDist)
|
|
=> extDist
|
|
.Where(kv => kv.Value > 0)
|
|
.Select(kv => new { Language = GetLanguageName(kv.Key), kv.Value })
|
|
.GroupBy(x => x.Language, StringComparer.OrdinalIgnoreCase)
|
|
.Select(group => new { Language = group.Key, Count = group.Sum(item => item.Value) })
|
|
.OrderByDescending(x => x.Count)
|
|
.Take(6)
|
|
.Select(x => $"{x.Language}: {x.Count} file(s)")
|
|
.ToList();
|
|
|
|
private static List<string> BuildLanguageWorkflow(List<KeyValuePair<string, int>> extDist)
|
|
=> CodeLanguageCatalog.BuildWorkspaceWorkflowSummaries(extDist.Select(x => x.Key)).ToList();
|
|
|
|
private static async Task<(string? Branch, string? Remote)> GetGitInfoAsync(
|
|
string folder, CancellationToken ct)
|
|
{
|
|
if (!Directory.Exists(Path.Combine(folder, ".git")))
|
|
return (null, null);
|
|
|
|
string? branch = null;
|
|
string? remote = null;
|
|
|
|
try
|
|
{
|
|
branch = await RunGitAsync(folder, "rev-parse --abbrev-ref HEAD", ct).ConfigureAwait(false);
|
|
remote = await RunGitAsync(folder, "remote get-url origin", ct).ConfigureAwait(false);
|
|
}
|
|
catch { /* Git 없거나 실패 */ }
|
|
|
|
return (branch?.Trim(), remote?.Trim());
|
|
}
|
|
|
|
private static async Task<string?> RunGitAsync(string folder, string args, CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
using var process = new Process();
|
|
process.StartInfo = new ProcessStartInfo
|
|
{
|
|
FileName = "git",
|
|
Arguments = args,
|
|
WorkingDirectory = folder,
|
|
RedirectStandardOutput = true,
|
|
RedirectStandardError = true,
|
|
UseShellExecute = false,
|
|
CreateNoWindow = true,
|
|
};
|
|
process.Start();
|
|
var output = await process.StandardOutput.ReadToEndAsync(ct).ConfigureAwait(false);
|
|
await process.WaitForExitAsync(ct).ConfigureAwait(false);
|
|
return process.ExitCode == 0 ? output.Trim() : null;
|
|
}
|
|
catch { return null; }
|
|
}
|
|
|
|
/// <summary>경로의 각 디렉토리 세그먼트가 SkipDirs에 해당하는지 검사.</summary>
|
|
private static bool ShouldSkipPath(string dirPath)
|
|
{
|
|
var span = dirPath.AsSpan();
|
|
while (span.Length > 0)
|
|
{
|
|
var sepIdx = span.IndexOfAny(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
|
var segment = sepIdx >= 0 ? span[..sepIdx] : span;
|
|
if (segment.Length > 0 && SkipDirs.Contains(segment.ToString()))
|
|
return true;
|
|
if (sepIdx < 0) break;
|
|
span = span[(sepIdx + 1)..];
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private static string GetLanguageName(string ext) => ext.ToLowerInvariant() switch
|
|
{
|
|
".cs" => "C#",
|
|
".ts" or ".tsx" => "TypeScript",
|
|
".js" or ".jsx" => "JavaScript",
|
|
".py" => "Python",
|
|
".rs" => "Rust",
|
|
".go" => "Go",
|
|
".java" => "Java",
|
|
".cpp" or ".cc" or ".cxx" => "C++",
|
|
".c" => "C",
|
|
".rb" => "Ruby",
|
|
".php" => "PHP",
|
|
".swift" => "Swift",
|
|
".kt" => "Kotlin",
|
|
".xaml" => "XAML",
|
|
".html" or ".htm" => "HTML",
|
|
".css" => "CSS",
|
|
_ => ext.TrimStart('.').ToUpperInvariant(),
|
|
};
|
|
}
|