From 4ef04c3c38a3950192b54386ab5cfefcf8ffc9c8 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Sun, 3 May 2026 09:05:46 +0000 Subject: [PATCH] =?UTF-8?q?fix(rules):=20=E6=94=B6=E7=B4=A7=E8=A7=84?= =?UTF-8?q?=E5=88=99=E8=AF=BB=E5=86=99=E5=81=A5=E5=A3=AE=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: phantom5099 <245659304+phantom5099@users.noreply.github.com> --- internal/context/source_rules_test.go | 2 +- internal/context/sources_test.go | 36 ++++++++++++ internal/rules/loader.go | 82 ++++++++++++++++++++++----- internal/rules/loader_test.go | 44 +++++++++++++- internal/rules/store.go | 45 ++++++++++++++- internal/rules/store_test.go | 77 +++++++++++++++++++++++++ 6 files changed, 266 insertions(+), 20 deletions(-) diff --git a/internal/context/source_rules_test.go b/internal/context/source_rules_test.go index ae9c1f58..c050fa3f 100644 --- a/internal/context/source_rules_test.go +++ b/internal/context/source_rules_test.go @@ -70,7 +70,7 @@ func TestRenderRulesDocumentBlockIncludesTruncationMarker(t *testing.T) { Content: "trimmed", Truncated: true, }) - if !strings.Contains(block, "[truncated to fit rule file limit]") { + if !strings.Contains(block, "[truncated to fit rules budget]") { t.Fatalf("expected truncation marker, got %q", block) } } diff --git a/internal/context/sources_test.go b/internal/context/sources_test.go index 31e11a9d..a6ec9606 100644 --- a/internal/context/sources_test.go +++ b/internal/context/sources_test.go @@ -77,6 +77,42 @@ func TestRulesPromptSourceSectionsRendersRules(t *testing.T) { } } +func TestRulesPromptSourceSectionsReflectRuleFileUpdatesOnNextBuild(t *testing.T) { + root := t.TempDir() + baseDir := filepath.Join(t.TempDir(), ".neocode") + if err := os.MkdirAll(baseDir, 0o755); err != nil { + t.Fatalf("mkdir baseDir: %v", err) + } + if _, err := rules.WriteGlobalRule(context.Background(), baseDir, "global-v1"); err != nil { + t.Fatalf("WriteGlobalRule(v1) error = %v", err) + } + + source := newRulesPromptSource(rules.NewLoader(baseDir)) + buildInput := BuildInput{Metadata: Metadata{ProjectRoot: root, Workdir: root}} + + firstSections, err := source.Sections(context.Background(), buildInput) + if err != nil { + t.Fatalf("Sections(first) error = %v", err) + } + firstPrompt := renderPromptSection(firstSections[0]) + if !strings.Contains(firstPrompt, "global-v1") { + t.Fatalf("expected first prompt to include global-v1, got %q", firstPrompt) + } + + if _, err := rules.WriteGlobalRule(context.Background(), baseDir, "global-v2"); err != nil { + t.Fatalf("WriteGlobalRule(v2) error = %v", err) + } + + secondSections, err := source.Sections(context.Background(), buildInput) + if err != nil { + t.Fatalf("Sections(second) error = %v", err) + } + secondPrompt := renderPromptSection(secondSections[0]) + if !strings.Contains(secondPrompt, "global-v2") || strings.Contains(secondPrompt, "global-v1") { + t.Fatalf("expected second prompt to reflect latest global rule, got %q", secondPrompt) + } +} + func TestCorePromptSourceSectionsHonorsCancellation(t *testing.T) { t.Parallel() diff --git a/internal/rules/loader.go b/internal/rules/loader.go index 1208afbe..c7235c9b 100644 --- a/internal/rules/loader.go +++ b/internal/rules/loader.go @@ -10,13 +10,14 @@ import ( ) const ( - agentsFileName = "AGENTS.md" - documentRuneLimit = 4000 - defaultRulesDir = ".neocode" + agentsFileName = "AGENTS.md" + documentReadRuneLimit = 16000 + snapshotRuneLimit = 4000 + defaultRulesDir = ".neocode" ) -// DefaultTruncationNotice 是规则文件超出注入预算时附加的统一提示。 -const DefaultTruncationNotice = "\n[truncated to fit rule file limit]\n" +// DefaultTruncationNotice 是规则内容超出注入预算时附加的统一提示。 +const DefaultTruncationNotice = "\n[truncated to fit rules budget]\n" // Document 表示单个规则文件的已加载内容快照。 type Document struct { @@ -62,10 +63,11 @@ func (l *fileLoader) Load(ctx context.Context, projectRoot string) (Snapshot, er return Snapshot{}, err } - return Snapshot{ + snapshot := Snapshot{ GlobalAGENTS: globalDoc, ProjectAGENTS: projectDoc, - }, nil + } + return enforceSnapshotBudget(snapshot), nil } // loadProjectDocument 读取项目根下的 AGENTS.md 作为项目规则。 @@ -102,17 +104,61 @@ func resolveBaseDir(baseDir string) string { return filepath.Clean(trimmed) } - home := strings.TrimSpace(os.Getenv("HOME")) - if !filepath.IsAbs(home) { - var err error - home, err = os.UserHomeDir() - if err != nil || !filepath.IsAbs(strings.TrimSpace(home)) { - return "" - } + home, err := os.UserHomeDir() + if err != nil || !filepath.IsAbs(strings.TrimSpace(home)) { + return "" } return filepath.Join(home, defaultRulesDir) } +// enforceSnapshotBudget 按项目规则优先级裁剪合并后的规则快照总预算。 +func enforceSnapshotBudget(snapshot Snapshot) Snapshot { + remaining := snapshotRuneLimit + + snapshot.ProjectAGENTS, remaining = truncateDocumentToBudget(snapshot.ProjectAGENTS, remaining) + snapshot.GlobalAGENTS, remaining = truncateDocumentToBudget(snapshot.GlobalAGENTS, remaining) + + return snapshot +} + +// truncateDocumentToBudget 按剩余预算裁剪单个规则文档,并尽量保持 Markdown 结构闭合。 +func truncateDocumentToBudget(document Document, budget int) (Document, int) { + content := strings.TrimSpace(document.Content) + if content == "" { + document.Content = "" + return document, maxInt(budget, 0) + } + + if budget <= 0 { + document.Content = "" + document.Truncated = true + return document, 0 + } + + trimmed, truncated := truncateRuleMarkdown(content, budget) + document.Content = trimmed + document.Truncated = document.Truncated || truncated + return document, maxInt(budget-runeCount(trimmed), 0) +} + +// truncateRuleMarkdown 按 rune 数量裁剪规则文本,并在需要时补齐未闭合的代码块围栏。 +func truncateRuleMarkdown(input string, max int) (string, bool) { + trimmed, truncated := truncateRunes(strings.TrimSpace(input), max) + if !truncated { + return trimmed, false + } + + trimmed = strings.TrimRight(trimmed, "\n") + if strings.Count(trimmed, "```")%2 == 1 { + if max > len([]rune("\n```")) { + trimmed, _ = truncateRunes(trimmed, max-len([]rune("\n```"))) + trimmed = strings.TrimRight(trimmed, "\n") + } + trimmed += "\n```" + } + return trimmed, true +} + // truncateRunes 按 rune 数量裁剪文本,避免破坏 UTF-8 多字节字符。 func truncateRunes(input string, max int) (string, bool) { if max <= 0 { @@ -130,3 +176,11 @@ func truncateRunes(input string, max int) (string, bool) { func runeCount(input string) int { return utf8.RuneCountInString(input) } + +// maxInt 返回两个整数中的较大值,用于避免预算结果出现负数。 +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/internal/rules/loader_test.go b/internal/rules/loader_test.go index de8059ae..61f9986b 100644 --- a/internal/rules/loader_test.go +++ b/internal/rules/loader_test.go @@ -66,7 +66,7 @@ func TestLoaderLoadTruncatesLongDocument(t *testing.T) { if err := os.MkdirAll(baseDir, 0o755); err != nil { t.Fatalf("mkdir baseDir: %v", err) } - large := strings.Repeat("规", documentRuneLimit+12) + large := strings.Repeat("规", snapshotRuneLimit+12) if err := os.WriteFile(filepath.Join(baseDir, agentsFileName), []byte(large), 0o644); err != nil { t.Fatalf("write AGENTS.md: %v", err) } @@ -78,11 +78,40 @@ func TestLoaderLoadTruncatesLongDocument(t *testing.T) { if !snapshot.GlobalAGENTS.Truncated { t.Fatalf("expected truncated global document") } - if runeCount(snapshot.GlobalAGENTS.Content) != documentRuneLimit { + if runeCount(snapshot.GlobalAGENTS.Content) != snapshotRuneLimit { t.Fatalf("unexpected truncated length = %d", runeCount(snapshot.GlobalAGENTS.Content)) } } +func TestLoaderLoadEnforcesCombinedRulesBudget(t *testing.T) { + baseDir := filepath.Join(t.TempDir(), ".neocode") + projectRoot := t.TempDir() + if err := os.MkdirAll(baseDir, 0o755); err != nil { + t.Fatalf("mkdir baseDir: %v", err) + } + + projectContent := strings.Repeat("甲", snapshotRuneLimit-10) + globalContent := strings.Repeat("乙", 32) + if err := os.WriteFile(filepath.Join(projectRoot, agentsFileName), []byte(projectContent), 0o644); err != nil { + t.Fatalf("write project AGENTS.md: %v", err) + } + if err := os.WriteFile(filepath.Join(baseDir, agentsFileName), []byte(globalContent), 0o644); err != nil { + t.Fatalf("write global AGENTS.md: %v", err) + } + + snapshot, err := NewLoader(baseDir).Load(context.Background(), projectRoot) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if !snapshot.GlobalAGENTS.Truncated { + t.Fatalf("expected global rules to be truncated by combined budget") + } + total := runeCount(snapshot.ProjectAGENTS.Content) + runeCount(snapshot.GlobalAGENTS.Content) + if total != snapshotRuneLimit { + t.Fatalf("combined rule length = %d, want %d", total, snapshotRuneLimit) + } +} + func TestLoaderLoadReturnsEmptySnapshotWhenFilesAreMissing(t *testing.T) { baseDir := filepath.Join(t.TempDir(), ".neocode") snapshot, err := NewLoader(baseDir).Load(context.Background(), t.TempDir()) @@ -156,6 +185,17 @@ func TestResolveBaseDirCleansExplicitBaseDir(t *testing.T) { } } +func TestTruncateRuleMarkdownClosesCodeFence(t *testing.T) { + input := "before\n```go\nfmt.Println(\"x\")\n" + got, truncated := truncateRuleMarkdown(input, len([]rune("before\n```go\nfmt.Pri"))) + if !truncated { + t.Fatalf("expected truncated markdown") + } + if !strings.HasSuffix(got, "\n```") { + t.Fatalf("expected closing fence, got %q", got) + } +} + func TestTruncateRunesWithZeroBudget(t *testing.T) { got, truncated := truncateRunes("规则", 0) if got != "" || !truncated { diff --git a/internal/rules/store.go b/internal/rules/store.go index 9ec007c2..73a7850a 100644 --- a/internal/rules/store.go +++ b/internal/rules/store.go @@ -5,12 +5,20 @@ import ( "fmt" "os" "path/filepath" + "runtime" "strings" + "time" "unicode/utf8" ) const rulesFilePermission = 0o644 +var ( + renameFile = os.Rename + removeFile = os.Remove + sleepFn = time.Sleep +) + // GlobalRulePath 返回全局规则文件 AGENTS.md 的固定路径。 func GlobalRulePath(baseDir string) string { resolvedBaseDir := resolveBaseDir(baseDir) @@ -103,8 +111,11 @@ func readRuleDocument(path string) (Document, error) { } return Document{}, fmt.Errorf("rules: read %s: %w", path, err) } + if !utf8.Valid(data) { + return Document{}, fmt.Errorf("rules: read %s: content is not valid UTF-8", path) + } - content, truncated := truncateRunes(strings.TrimSpace(string(data)), documentRuneLimit) + content, truncated := truncateRunes(strings.TrimSpace(string(data)), documentReadRuneLimit) return Document{ Path: path, Content: content, @@ -142,8 +153,36 @@ func writeRuleFile(path string, content string) error { if err := os.Chmod(tempPath, rulesFilePermission); err != nil { return fmt.Errorf("rules: chmod temp file for %s: %w", path, err) } - if err := os.Rename(tempPath, path); err != nil { - return fmt.Errorf("rules: commit file %s: %w", path, err) + if err := commitWithRetry(tempPath, path, runtime.GOOS == "windows"); err != nil { + return err } return nil } + +// commitWithRetry 在最终提交规则文件时处理临时占用导致的瞬时失败。 +func commitWithRetry(tempPath, path string, allowReplace bool) error { + var last error + for attempt := 0; attempt < 5; attempt++ { + if err := renameWithReplace(tempPath, path, allowReplace); err == nil { + return nil + } else { + last = err + } + sleepFn(time.Duration(25*(1<