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(),
};
}