Skip to content

Commit 15c569d

Browse files
feat(lint): separate generated rule state from policy
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1 parent 0cd499d commit 15c569d

3 files changed

Lines changed: 367 additions & 29 deletions

File tree

internal/cli/lint.go

Lines changed: 260 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,15 @@ package cli
22

33
import (
44
"fmt"
5+
"os"
56
"path/filepath"
67
"regexp"
8+
"reflect"
79

810
"github.com/spf13/cobra"
911
"github.com/spf13/viper"
1012
"github.com/tae2089/trace"
13+
"go.yaml.in/yaml/v3"
1114

1215
"github.com/tae2089/code-context-graph/internal/docs"
1316
"github.com/tae2089/code-context-graph/internal/pathutil"
@@ -17,25 +20,23 @@ import (
1720
// @intent strict 모드에서 실제 실패로 간주할 lint 항목만 다시 집계한다.
1821
// @domainRule action: ignore로 선언된 규칙은 strict 실패 수에서 제외한다.
1922
func countNonIgnored(report *docs.LintReport) int {
20-
rules := viper.Get("rules")
23+
return countNonIgnoredWithRules(report, configuredLintRules())
24+
}
25+
26+
func countNonIgnoredWithRules(report *docs.LintReport, rules []lintRule) int {
2127
ignoreSet := map[string]bool{}
2228
var ignoreRegexps []*regexp.Regexp
2329

24-
if ruleSlice, ok := rules.([]any); ok {
25-
for _, r := range ruleSlice {
26-
if rm, ok := r.(map[string]any); ok {
27-
action, _ := rm["action"].(string)
28-
pattern, _ := rm["pattern"].(string)
29-
if action == "ignore" && pattern != "" {
30-
if pathutil.IsRegexPattern(pattern) {
31-
if re, err := regexp.Compile(pattern); err == nil {
32-
ignoreRegexps = append(ignoreRegexps, re)
33-
}
34-
} else {
35-
ignoreSet[pattern] = true
36-
}
37-
}
30+
for _, rule := range rules {
31+
if rule.Action != "ignore" || rule.Pattern == "" {
32+
continue
33+
}
34+
if pathutil.IsRegexPattern(rule.Pattern) {
35+
if re, err := regexp.Compile(rule.Pattern); err == nil {
36+
ignoreRegexps = append(ignoreRegexps, re)
3837
}
38+
} else {
39+
ignoreSet[rule.Pattern] = true
3940
}
4041
}
4142

@@ -95,6 +96,204 @@ func countNonIgnored(report *docs.LintReport) int {
9596
return total
9697
}
9798

99+
func configuredLintRules() []lintRule {
100+
var rules []lintRule
101+
if err := viper.UnmarshalKey("rules", &rules); err == nil && len(rules) > 0 {
102+
return rules
103+
}
104+
return flattenLintRules(viper.Get("rules"))
105+
}
106+
107+
func flattenLintRules(raw any) []lintRule {
108+
var out []lintRule
109+
switch rules := raw.(type) {
110+
case []any:
111+
for _, item := range rules {
112+
if rule, ok := parseLintRule(item); ok {
113+
out = append(out, rule)
114+
}
115+
}
116+
case []map[string]any:
117+
for _, item := range rules {
118+
if rule, ok := parseLintRule(item); ok {
119+
out = append(out, rule)
120+
}
121+
}
122+
default:
123+
rv := reflect.ValueOf(raw)
124+
if rv.IsValid() && rv.Kind() == reflect.Slice {
125+
for i := 0; i < rv.Len(); i++ {
126+
if rule, ok := parseLintRule(rv.Index(i).Interface()); ok {
127+
out = append(out, rule)
128+
}
129+
}
130+
}
131+
}
132+
return out
133+
}
134+
135+
func parseLintRule(raw any) (lintRule, bool) {
136+
switch item := raw.(type) {
137+
case lintRule:
138+
return item, true
139+
case map[string]any:
140+
return lintRuleFromMapLookup(func(key string) (any, bool) {
141+
v, ok := item[key]
142+
return v, ok
143+
})
144+
default:
145+
rv := reflect.ValueOf(raw)
146+
if rv.IsValid() && rv.Kind() == reflect.Map {
147+
return lintRuleFromMapLookup(func(key string) (any, bool) {
148+
for _, candidate := range rv.MapKeys() {
149+
if fmt.Sprint(candidate.Interface()) == key {
150+
return rv.MapIndex(candidate).Interface(), true
151+
}
152+
}
153+
return nil, false
154+
})
155+
}
156+
return lintRule{}, false
157+
}
158+
}
159+
160+
func lintRuleFromMapLookup(lookup func(string) (any, bool)) (lintRule, bool) {
161+
rule := lintRule{}
162+
if v, ok := lookup("pattern"); ok {
163+
rule.Pattern, _ = v.(string)
164+
}
165+
if v, ok := lookup("category"); ok {
166+
rule.Category, _ = v.(string)
167+
}
168+
if v, ok := lookup("action"); ok {
169+
rule.Action, _ = v.(string)
170+
}
171+
if v, ok := lookup("auto"); ok {
172+
rule.Auto, _ = v.(bool)
173+
}
174+
if v, ok := lookup("created"); ok {
175+
rule.Created, _ = v.(string)
176+
}
177+
return rule, rule.Pattern != "" || rule.Category != "" || rule.Action != ""
178+
}
179+
180+
type lintRuleDoc struct {
181+
Rules []lintRule `yaml:"rules"`
182+
}
183+
184+
type lintRule struct {
185+
Pattern string `yaml:"pattern"`
186+
Category string `yaml:"category"`
187+
Action string `yaml:"action"`
188+
Auto bool `yaml:"auto,omitempty"`
189+
Created string `yaml:"created,omitempty"`
190+
}
191+
192+
func currentLintStateDir(historyDir string) string {
193+
if historyDir != "" {
194+
return historyDir
195+
}
196+
return ".ccg"
197+
}
198+
199+
func mergeLintRulesWithAutoRules(stateDir string) ([]lintRule, error) {
200+
autoRules, err := docs.LoadAutoRules(filepath.Join(stateDir, "auto-rules.yaml"))
201+
if err != nil {
202+
return nil, err
203+
}
204+
baseRules := configuredLintRules()
205+
merged := make([]lintRule, 0, len(baseRules)+len(autoRules.Rules))
206+
for _, rule := range baseRules {
207+
merged = append(merged, rule)
208+
}
209+
for _, rule := range autoRules.Rules {
210+
merged = append(merged, lintRule{
211+
Pattern: rule.Pattern,
212+
Category: rule.Category,
213+
Action: rule.Action,
214+
Auto: rule.Auto,
215+
Created: rule.Created,
216+
})
217+
}
218+
return merged, nil
219+
}
220+
221+
func migrateAutoRulesFromConfig(cfgPath, stateDir string) (int, error) {
222+
data, err := os.ReadFile(cfgPath)
223+
if err != nil {
224+
return 0, err
225+
}
226+
227+
var root yaml.Node
228+
if err := yaml.Unmarshal(data, &root); err != nil {
229+
return 0, err
230+
}
231+
232+
autoRules, err := docs.LoadAutoRules(filepath.Join(stateDir, "auto-rules.yaml"))
233+
if err != nil {
234+
return 0, err
235+
}
236+
237+
triggered := make([]string, 0)
238+
if len(root.Content) == 0 {
239+
return 0, nil
240+
}
241+
doc := root.Content[0]
242+
if doc.Kind != yaml.MappingNode {
243+
return 0, nil
244+
}
245+
246+
for i := 0; i+1 < len(doc.Content); i += 2 {
247+
key := doc.Content[i]
248+
value := doc.Content[i+1]
249+
if key.Value != "rules" || value.Kind != yaml.SequenceNode {
250+
continue
251+
}
252+
253+
kept := make([]*yaml.Node, 0, len(value.Content))
254+
for _, item := range value.Content {
255+
rule, isAuto := decodeLintRuleNode(item)
256+
if isAuto {
257+
if rule.Category != "" && rule.Pattern != "" {
258+
triggered = append(triggered, rule.Category+":"+rule.Pattern)
259+
}
260+
continue
261+
}
262+
kept = append(kept, item)
263+
}
264+
value.Content = kept
265+
break
266+
}
267+
268+
if len(triggered) == 0 {
269+
return 0, nil
270+
}
271+
272+
added := autoRules.Upsert(triggered)
273+
if err := autoRules.Save(filepath.Join(stateDir, "auto-rules.yaml")); err != nil {
274+
return 0, err
275+
}
276+
out, err := yaml.Marshal(&root)
277+
if err != nil {
278+
return 0, err
279+
}
280+
if err := os.WriteFile(cfgPath, out, 0o644); err != nil {
281+
return 0, err
282+
}
283+
return len(added), nil
284+
}
285+
286+
func decodeLintRuleNode(node *yaml.Node) (lintRule, bool) {
287+
var rule lintRule
288+
if node == nil || node.Kind != yaml.MappingNode {
289+
return rule, false
290+
}
291+
if err := node.Decode(&rule); err != nil {
292+
return lintRule{}, false
293+
}
294+
return rule, rule.Auto
295+
}
296+
98297
// newLintCmd creates the docs lint command.
99298
// @intent 문서 품질 점검과 Twice Rule 자동 기록을 하나의 CLI 흐름으로 제공한다.
100299
// @domainRule 같은 이슈가 두 번 연속 발견되면 warn 규칙 후보로 승격한다.
@@ -106,6 +305,7 @@ func newLintCmd(deps *Deps) *cobra.Command {
106305
var excludePatterns []string
107306
var strict bool
108307
var historyDir string
308+
var migrateAutoRules bool
109309

110310
cmd := &cobra.Command{
111311
Use: "lint",
@@ -116,6 +316,30 @@ func newLintCmd(deps *Deps) *cobra.Command {
116316
return errDBNotInitialized
117317
}
118318

319+
stateDir := currentLintStateDir(historyDir)
320+
lintRules := configuredLintRules()
321+
if migrateAutoRules {
322+
cfgPath := viper.ConfigFileUsed()
323+
if cfgPath == "" {
324+
cfgPath = ".ccg.yaml"
325+
}
326+
migrated, err := migrateAutoRulesFromConfig(cfgPath, stateDir)
327+
if err != nil {
328+
return trace.Wrap(err, "migrate auto rules")
329+
}
330+
if migrated == 0 {
331+
fmt.Fprintln(stdout(cmd), "Nothing to migrate.")
332+
} else {
333+
fmt.Fprintf(stdout(cmd), "Migrated %d auto rules to .ccg/auto-rules.yaml\n", migrated)
334+
}
335+
return nil
336+
}
337+
if merged, err := mergeLintRulesWithAutoRules(stateDir); err != nil {
338+
deps.Logger.Warn("load auto rules for lint failed", "error", err)
339+
} else {
340+
lintRules = merged
341+
}
342+
119343
absOut, err := filepath.Abs(resolveOutDir(outDir))
120344
if err != nil {
121345
return trace.Wrap(err, "resolve out path")
@@ -211,10 +435,7 @@ func newLintCmd(deps *Deps) *cobra.Command {
211435

212436
// Twice Rule: compare with previous run history
213437
{
214-
hDir := historyDir
215-
if hDir == "" {
216-
hDir = ".ccg"
217-
}
438+
hDir := stateDir
218439
histPath := filepath.Join(hDir, "lint-history.json")
219440

220441
history, err := docs.LoadHistory(histPath)
@@ -254,22 +475,30 @@ func newLintCmd(deps *Deps) *cobra.Command {
254475
}
255476

256477
if len(triggered) > 0 {
257-
fmt.Fprintf(out, "Twice Rule triggered (%d):\n", len(triggered))
258-
for _, key := range triggered {
259-
fmt.Fprintf(out, " %s → added to .ccg.yaml rules (warn)\n", key)
260-
}
261-
fmt.Fprintln(out)
262-
263-
cfgPath := ".ccg.yaml"
264-
if writeErr := docs.WriteYamlRules(cfgPath, triggered); writeErr != nil {
265-
deps.Logger.Warn("write yaml rules failed", "error", writeErr)
478+
autoRulesPath := filepath.Join(hDir, "auto-rules.yaml")
479+
autoRules, loadErr := docs.LoadAutoRules(autoRulesPath)
480+
if loadErr != nil {
481+
deps.Logger.Warn("load auto rules failed", "error", loadErr)
482+
} else {
483+
added := autoRules.Upsert(triggered)
484+
if saveErr := autoRules.Save(autoRulesPath); saveErr != nil {
485+
deps.Logger.Warn("save auto rules failed", "error", saveErr)
486+
}
487+
fmt.Fprintf(out, "Twice Rule triggered (%d):\n", len(triggered))
488+
for _, key := range triggered {
489+
fmt.Fprintf(out, " %s → recorded in .ccg/auto-rules.yaml (warn)\n", key)
490+
}
491+
fmt.Fprintln(out)
492+
if len(added) == 0 {
493+
deps.Logger.Debug("no new auto rules added", "path", autoRulesPath)
494+
}
266495
}
267496
}
268497
}
269498
}
270499

271500
if strict {
272-
strictTotal := countNonIgnored(report)
501+
strictTotal := countNonIgnoredWithRules(report, lintRules)
273502
if strictTotal > 0 {
274503
return trace.New(fmt.Sprintf("lint found %d issues", strictTotal))
275504
}
@@ -283,5 +512,7 @@ func newLintCmd(deps *Deps) *cobra.Command {
283512
cmd.Flags().StringArrayVar(&excludePatterns, "exclude", nil, "Exclude files/paths matching pattern (repeatable)")
284513
cmd.Flags().BoolVar(&strict, "strict", false, "Exit with error if any issues are found (useful for CI/pre-commit)")
285514
cmd.Flags().StringVar(&historyDir, "history-dir", "", "Directory for lint history (default: .ccg)")
515+
cmd.Flags().BoolVar(&migrateAutoRules, "migrate-auto-rules", false, "Move legacy auto: true lint rules from .ccg.yaml into .ccg/auto-rules.yaml")
516+
_ = cmd.Flags().MarkHidden("migrate-auto-rules")
286517
return cmd
287518
}

0 commit comments

Comments
 (0)