Initial commit to new repository

This commit is contained in:
2026-04-03 18:22:19 +09:00
commit 4458bb0f52
7672 changed files with 452440 additions and 0 deletions

View File

@@ -0,0 +1,206 @@
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
namespace AxCopilot.Services.Agent;
/// <summary>
/// 프로젝트별 규칙 파일(.ax/rules/*.md)을 로드하고 컨텍스트에 맞는 규칙을 선별합니다.
/// 각 규칙 파일은 YAML 프론트매터로 적용 조건을 정의합니다.
/// </summary>
public static class ProjectRulesService
{
/// <summary>파싱된 프로젝트 규칙.</summary>
public class ProjectRule
{
/// <summary>규칙 파일 경로.</summary>
public string FilePath { get; set; } = "";
/// <summary>규칙 이름 (프론트매터 name 또는 파일명).</summary>
public string Name { get; set; } = "";
/// <summary>규칙 설명.</summary>
public string Description { get; set; } = "";
/// <summary>적용 대상 글로브 패턴. 예: "*.cs", "src/**/*.ts"</summary>
public string AppliesTo { get; set; } = "";
/// <summary>적용 시점. 예: "always", "code-review", "document", "refactor"</summary>
public string When { get; set; } = "always";
/// <summary>규칙 본문 (프론트매터 제외).</summary>
public string Body { get; set; } = "";
}
// YAML 프론트매터 경계
private static readonly Regex FrontMatterRegex = new(
@"^---\s*\n(.*?)\n---\s*\n",
RegexOptions.Singleline | RegexOptions.Compiled);
// YAML 키-값 파싱 (단순 1줄 값만)
private static readonly Regex YamlKeyValue = new(
@"^\s*(\w[\w-]*)\s*:\s*(.+?)\s*$",
RegexOptions.Multiline | RegexOptions.Compiled);
/// <summary>
/// 작업 폴더에서 .ax/rules/ 디렉토리의 모든 규칙을 로드합니다.
/// 최대 3단계 상위 폴더까지 .ax/rules/ 를 탐색합니다.
/// </summary>
public static List<ProjectRule> LoadRules(string workFolder)
{
var rules = new List<ProjectRule>();
if (string.IsNullOrEmpty(workFolder)) return rules;
var rulesDir = FindRulesDirectory(workFolder);
if (rulesDir == null) return rules;
try
{
foreach (var file in Directory.GetFiles(rulesDir, "*.md"))
{
var rule = ParseRuleFile(file);
if (rule != null)
rules.Add(rule);
}
}
catch { /* 디렉토리 읽기 실패 시 빈 목록 반환 */ }
return rules;
}
/// <summary>
/// 컨텍스트에 맞는 규칙만 필터링합니다.
/// </summary>
/// <param name="rules">전체 규칙 목록</param>
/// <param name="when">현재 컨텍스트 (예: "code-review", "document", "always")</param>
/// <param name="filePaths">현재 작업 대상 파일 경로들 (applies-to 매칭용)</param>
public static List<ProjectRule> FilterRules(
List<ProjectRule> rules, string when = "always", IEnumerable<string>? filePaths = null)
{
var result = new List<ProjectRule>();
foreach (var rule in rules)
{
// when 조건 체크
var ruleWhen = rule.When.ToLowerInvariant().Trim();
if (ruleWhen != "always" && ruleWhen != when.ToLowerInvariant())
continue;
// applies-to 조건 체크
if (!string.IsNullOrEmpty(rule.AppliesTo) && filePaths != null)
{
var pattern = rule.AppliesTo.Trim();
if (!filePaths.Any(fp => MatchesGlob(fp, pattern)))
continue;
}
result.Add(rule);
}
return result;
}
/// <summary>
/// 규칙 목록을 시스템 프롬프트용 텍스트로 포맷합니다.
/// </summary>
public static string FormatForSystemPrompt(List<ProjectRule> rules)
{
if (rules.Count == 0) return "";
var sb = new StringBuilder();
sb.AppendLine("\n## 프로젝트 규칙 (.ax/rules/)");
sb.AppendLine("아래 규칙을 반드시 준수하세요:\n");
foreach (var rule in rules)
{
if (!string.IsNullOrEmpty(rule.Name))
sb.AppendLine($"### {rule.Name}");
if (!string.IsNullOrEmpty(rule.Description))
sb.AppendLine($"*{rule.Description}*\n");
sb.AppendLine(rule.Body.Trim());
sb.AppendLine();
}
return sb.ToString();
}
/// <summary>.ax/rules/ 디렉토리를 작업 폴더에서 최대 3단계 상위까지 탐색합니다.</summary>
internal static string? FindRulesDirectory(string workFolder)
{
var dir = workFolder;
for (int i = 0; i < 3; i++)
{
if (string.IsNullOrEmpty(dir)) break;
var rulesPath = Path.Combine(dir, ".ax", "rules");
if (Directory.Exists(rulesPath)) return rulesPath;
dir = Directory.GetParent(dir)?.FullName;
}
return null;
}
/// <summary>규칙 파일을 파싱합니다 (YAML 프론트매터 + 본문).</summary>
internal static ProjectRule? ParseRuleFile(string filePath)
{
try
{
var content = File.ReadAllText(filePath, Encoding.UTF8);
if (string.IsNullOrWhiteSpace(content)) return null;
var rule = new ProjectRule
{
FilePath = filePath,
Name = Path.GetFileNameWithoutExtension(filePath),
};
// 프론트매터 파싱
var fmMatch = FrontMatterRegex.Match(content);
if (fmMatch.Success)
{
var yaml = fmMatch.Groups[1].Value;
foreach (Match kv in YamlKeyValue.Matches(yaml))
{
var key = kv.Groups[1].Value.ToLowerInvariant();
var val = kv.Groups[2].Value.Trim().Trim('"', '\'');
switch (key)
{
case "name": rule.Name = val; break;
case "description": rule.Description = val; break;
case "applies-to" or "appliesto": rule.AppliesTo = val; break;
case "when": rule.When = val; break;
}
}
rule.Body = content[(fmMatch.Index + fmMatch.Length)..];
}
else
{
rule.Body = content;
}
return string.IsNullOrWhiteSpace(rule.Body) ? null : rule;
}
catch
{
return null;
}
}
/// <summary>간단한 글로브 패턴 매칭 (*, ** 지원).</summary>
private static bool MatchesGlob(string path, string pattern)
{
// "*.cs" → 확장자 매칭
if (pattern.StartsWith("*."))
return path.EndsWith(pattern[1..], StringComparison.OrdinalIgnoreCase);
// "**/*.cs" → 경로 내 확장자 매칭
if (pattern.StartsWith("**/"))
{
var subPattern = pattern[3..];
return MatchesGlob(Path.GetFileName(path), subPattern);
}
// 정확한 파일명 매칭
return Path.GetFileName(path).Equals(pattern, StringComparison.OrdinalIgnoreCase);
}
}