207 lines
7.1 KiB
C#
207 lines
7.1 KiB
C#
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);
|
|
}
|
|
}
|