Skip to content

Commit ae09de9

Browse files
committed
fix: track docs application package
1 parent e76601f commit ae09de9

12 files changed

Lines changed: 2669 additions & 1 deletion

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
# Plugin cache
1616
.omc/
1717

18-
docs/
18+
/docs/
1919
.ccg
2020
.claude/**
2121
.python-version

internal/app/docs/autorules.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package docs
2+
3+
import (
4+
"sort"
5+
"strings"
6+
"time"
7+
8+
"go.yaml.in/yaml/v3"
9+
)
10+
11+
// AutoRule stores generated warn-only lint state separate from human policy.
12+
// @intent Twice Rule이 만든 자동 warn 규칙을 사람이 관리하는 설정 파일과 분리 저장한다.
13+
type AutoRule struct {
14+
Pattern string `yaml:"pattern"`
15+
Category string `yaml:"category"`
16+
Action string `yaml:"action"`
17+
Auto bool `yaml:"auto"`
18+
Created string `yaml:"created,omitempty"`
19+
}
20+
21+
// AutoRuleSet is the persisted generated-rule document written under .ccg/.
22+
// @intent generated lint state를 rules YAML shape로 직렬화해 기존 규칙 형태와 호환되게 유지한다.
23+
type AutoRuleSet struct {
24+
Rules []AutoRule `yaml:"rules"`
25+
}
26+
27+
// LoadAutoRules reads generated auto rules. Missing files return an empty set.
28+
// @intent generated lint rule 상태 파일이 없을 때도 lint가 정상적으로 동작하게 한다.
29+
func LoadAutoRules(files StateFiles, path string) (*AutoRuleSet, error) {
30+
set := &AutoRuleSet{Rules: []AutoRule{}}
31+
data, exists, err := files.ReadPath(path)
32+
if err != nil {
33+
return nil, err
34+
}
35+
if !exists {
36+
return set, nil
37+
}
38+
if err := yaml.Unmarshal(data, set); err != nil {
39+
return nil, err
40+
}
41+
if set.Rules == nil {
42+
set.Rules = []AutoRule{}
43+
}
44+
return set, nil
45+
}
46+
47+
// Upsert adds new generated warn rules for triggered lint keys.
48+
// @intent 같은 category+pattern 규칙을 중복 기록하지 않고 새 자동 규칙만 추가한다.
49+
func (s *AutoRuleSet) Upsert(triggered []string) []AutoRule {
50+
if s.Rules == nil {
51+
s.Rules = []AutoRule{}
52+
}
53+
existing := make(map[string]struct{}, len(s.Rules))
54+
for _, rule := range s.Rules {
55+
existing[autoRuleKey(rule.Category, rule.Pattern)] = struct{}{}
56+
}
57+
58+
var added []AutoRule
59+
for _, key := range triggered {
60+
category, pattern, ok := strings.Cut(key, ":")
61+
if !ok || category == "" || pattern == "" {
62+
continue
63+
}
64+
lookup := autoRuleKey(category, pattern)
65+
if _, ok := existing[lookup]; ok {
66+
continue
67+
}
68+
rule := AutoRule{
69+
Pattern: pattern,
70+
Category: category,
71+
Action: "warn",
72+
Auto: true,
73+
Created: time.Now().Format("2006-01-02"),
74+
}
75+
s.Rules = append(s.Rules, rule)
76+
added = append(added, rule)
77+
existing[lookup] = struct{}{}
78+
}
79+
80+
sort.Slice(s.Rules, func(i, j int) bool {
81+
if s.Rules[i].Category == s.Rules[j].Category {
82+
return s.Rules[i].Pattern < s.Rules[j].Pattern
83+
}
84+
return s.Rules[i].Category < s.Rules[j].Category
85+
})
86+
return added
87+
}
88+
89+
// Save writes generated auto rules, creating parent dirs if needed.
90+
// @intent generated lint state를 원자적으로 기록해 수동 정책 파일과 분리 유지한다.
91+
func (s *AutoRuleSet) Save(files StateFiles, path string) error {
92+
data, err := yaml.Marshal(s)
93+
if err != nil {
94+
return err
95+
}
96+
return files.WritePath(path, data)
97+
}
98+
99+
// autoRuleKey builds a stable map key for one generated lint rule.
100+
// @intent deduplicate auto-generated rules by category and pattern before persisting them.
101+
func autoRuleKey(category, pattern string) string {
102+
return category + "\x00" + pattern
103+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package docs
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strings"
7+
"testing"
8+
9+
"go.yaml.in/yaml/v3"
10+
)
11+
12+
func TestLoadAutoRules_MissingReturnsEmpty(t *testing.T) {
13+
path := filepath.Join(t.TempDir(), "auto-rules.yaml")
14+
15+
set, err := LoadAutoRules(testStateFiles, path)
16+
if err != nil {
17+
t.Fatalf("unexpected error: %v", err)
18+
}
19+
if set == nil {
20+
t.Fatal("expected non-nil set")
21+
}
22+
if len(set.Rules) != 0 {
23+
t.Fatalf("expected empty rules, got %v", set.Rules)
24+
}
25+
}
26+
27+
func TestAutoRuleSet_RoundTripPreservesFields(t *testing.T) {
28+
path := filepath.Join(t.TempDir(), "auto-rules.yaml")
29+
set := &AutoRuleSet{Rules: []AutoRule{{Pattern: "pkg/a.go::Foo", Category: "unannotated", Action: "warn", Auto: true, Created: "2026-05-03"}}}
30+
31+
if err := set.Save(testStateFiles, path); err != nil {
32+
t.Fatalf("save: %v", err)
33+
}
34+
35+
loaded, err := LoadAutoRules(testStateFiles, path)
36+
if err != nil {
37+
t.Fatalf("load: %v", err)
38+
}
39+
if len(loaded.Rules) != 1 {
40+
t.Fatalf("expected 1 rule, got %d", len(loaded.Rules))
41+
}
42+
rule := loaded.Rules[0]
43+
if rule.Pattern != "pkg/a.go::Foo" || rule.Category != "unannotated" || rule.Action != "warn" || !rule.Auto || rule.Created != "2026-05-03" {
44+
t.Fatalf("unexpected round-trip rule: %+v", rule)
45+
}
46+
}
47+
48+
func TestAutoRuleSet_UpsertAddsNew(t *testing.T) {
49+
set := &AutoRuleSet{}
50+
added := set.Upsert([]string{"unannotated:pkg/a.go::Foo"})
51+
52+
if len(added) != 1 {
53+
t.Fatalf("expected 1 added rule, got %d", len(added))
54+
}
55+
if len(set.Rules) != 1 {
56+
t.Fatalf("expected 1 stored rule, got %d", len(set.Rules))
57+
}
58+
rule := set.Rules[0]
59+
if rule.Pattern != "pkg/a.go::Foo" {
60+
t.Fatalf("pattern = %q, want pkg/a.go::Foo", rule.Pattern)
61+
}
62+
if rule.Category != "unannotated" || rule.Action != "warn" || !rule.Auto {
63+
t.Fatalf("unexpected rule metadata: %+v", rule)
64+
}
65+
if rule.Created == "" {
66+
t.Fatal("expected created date to be populated")
67+
}
68+
}
69+
70+
func TestAutoRuleSet_UpsertIsIdempotent(t *testing.T) {
71+
set := &AutoRuleSet{}
72+
set.Upsert([]string{"unannotated:pkg/a.go::Foo"})
73+
added := set.Upsert([]string{"unannotated:pkg/a.go::Foo"})
74+
75+
if len(added) != 0 {
76+
t.Fatalf("expected no new rules on duplicate upsert, got %d", len(added))
77+
}
78+
if len(set.Rules) != 1 {
79+
t.Fatalf("expected 1 stored rule, got %d", len(set.Rules))
80+
}
81+
}
82+
83+
func TestAutoRuleSet_SaveCreatesParentDir(t *testing.T) {
84+
path := filepath.Join(t.TempDir(), "nested", "state", "auto-rules.yaml")
85+
set := &AutoRuleSet{}
86+
set.Upsert([]string{"missing:pkg/b.go"})
87+
88+
if err := set.Save(testStateFiles, path); err != nil {
89+
t.Fatalf("save: %v", err)
90+
}
91+
if _, err := os.Stat(path); err != nil {
92+
t.Fatalf("expected saved file to exist: %v", err)
93+
}
94+
}
95+
96+
func TestAutoRuleSet_SaveUsesYamlPolicyShape(t *testing.T) {
97+
path := filepath.Join(t.TempDir(), "auto-rules.yaml")
98+
set := &AutoRuleSet{}
99+
set.Upsert([]string{"dead-ref:pkg/c.go::Bar"})
100+
101+
if err := set.Save(testStateFiles, path); err != nil {
102+
t.Fatalf("save: %v", err)
103+
}
104+
105+
data, err := os.ReadFile(path)
106+
if err != nil {
107+
t.Fatalf("read: %v", err)
108+
}
109+
content := string(data)
110+
for _, want := range []string{"rules:", "pattern: pkg/c.go::Bar", "category: dead-ref", "action: warn", "auto: true"} {
111+
if !strings.Contains(content, want) {
112+
t.Fatalf("expected %q in yaml, got:\n%s", want, content)
113+
}
114+
}
115+
116+
var loaded AutoRuleSet
117+
if err := yaml.Unmarshal(data, &loaded); err != nil {
118+
t.Fatalf("expected valid yaml shape, got error: %v", err)
119+
}
120+
if len(loaded.Rules) != 1 || loaded.Rules[0].Pattern != "pkg/c.go::Bar" {
121+
t.Fatalf("unexpected unmarshaled rules: %+v", loaded.Rules)
122+
}
123+
}

0 commit comments

Comments
 (0)