@@ -2,12 +2,15 @@ package cli
22
33import (
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 실패 수에서 제외한다.
1922func 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