using System.Diagnostics; using System.IO; using System.Linq; using System.Text; using System.Text.RegularExpressions; namespace AxCopilot.Services.Agent; /// /// 작업 폴더의 구조/기술스택/컨벤션을 분석하여 .ax-context.md를 자동 생성합니다. /// LLM 호출 없이 순수 파일 시스템 분석으로 동작합니다. /// 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 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", }; /// /// .ax-context.md가 없으면 생성합니다. 이미 있으면 기존 내용을 반환합니다. /// public static async Task 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); } /// /// 강제 재생성합니다. /// public static async Task 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; } /// /// 기존 .ax-context.md를 읽습니다. 없으면 null. /// 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 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> GetExtensionDistribution( string folder, CancellationToken ct) { var counts = new Dictionary(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 BuildDirectoryTree(string root, int maxDepth) { var result = new List(); BuildTreeRecursive(root, root, 0, maxDepth, result); return result; } private static void BuildTreeRecursive(string root, string current, int depth, int maxDepth, List result) { if (depth >= maxDepth || result.Count >= 30) return; IEnumerable 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(); 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 DetectContextFiles(string folder) { var files = new List(); 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 DetectAgentContextSummary(string folder) { var lines = new List(); 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 DetectKeyManifests(string folder) { var manifests = new List(); 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() .Take(3) .ToList(); if (matches.Count > 0) manifests.Add($"{label}: {string.Join(", ", matches)}"); } catch { } } return manifests; } private static List BuildLanguageSnapshot(List> 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 BuildLanguageWorkflow(List> 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 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; } } /// 경로의 각 디렉토리 세그먼트가 SkipDirs에 해당하는지 검사. 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(), }; }