Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion internal/context/source_rules_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
36 changes: 36 additions & 0 deletions internal/context/sources_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
82 changes: 68 additions & 14 deletions internal/rules/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 作为项目规则。
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
44 changes: 42 additions & 2 deletions internal/rules/loader_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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())
Expand Down Expand Up @@ -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 {
Expand Down
45 changes: 42 additions & 3 deletions internal/rules/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<<attempt)) * time.Millisecond)
}
return fmt.Errorf("rules: commit file %s: %w", path, last)
}

// renameWithReplace 在需要时先删除旧文件,再提交新的规则文件内容。
func renameWithReplace(tempPath, path string, allowReplace bool) error {
if err := renameFile(tempPath, path); err == nil {
return nil
} else if !allowReplace {
return err
}

if err := removeFile(path); err != nil && !os.IsNotExist(err) {
return err
}
return renameFile(tempPath, path)
}
Loading
Loading