Skip to content

Commit 313083e

Browse files
author
Test User
committed
refactor(cli): extract generic linter infrastructure for DRY
- Add ComponentLinter interface with generic lintComponent() pipeline - Create per-component linters (AgentLinter, CommandLinter, etc.) - Extract DetectXMLTags() and CheckSizeLimit() to shared helpers - Simplify lintSingleX() functions to one-line delegations - Simplify LintX() batch functions with common loop logic New files: - generic_linter.go: Core infrastructure and shared helpers - {agent,command,skill,settings,context,plugin}_linter.go: Component implementations Reduces ~350 lines of duplicated code across linter implementations.
1 parent ec68427 commit 313083e

14 files changed

Lines changed: 942 additions & 1005 deletions

internal/cli/agent_linter.go

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
7+
"github.com/dotcommander/cclint/internal/cue"
8+
"github.com/dotcommander/cclint/internal/discovery"
9+
"github.com/dotcommander/cclint/internal/scoring"
10+
)
11+
12+
// AgentLinter implements ComponentLinter for agent files.
13+
type AgentLinter struct {
14+
BaseLinter
15+
}
16+
17+
// NewAgentLinter creates a new AgentLinter.
18+
func NewAgentLinter() *AgentLinter {
19+
return &AgentLinter{}
20+
}
21+
22+
func (l *AgentLinter) Type() string {
23+
return "agent"
24+
}
25+
26+
func (l *AgentLinter) FileType() discovery.FileType {
27+
return discovery.FileTypeAgent
28+
}
29+
30+
func (l *AgentLinter) ParseContent(contents string) (map[string]interface{}, string, error) {
31+
return parseFrontmatter(contents)
32+
}
33+
34+
func (l *AgentLinter) ValidateCUE(validator *cue.Validator, data map[string]interface{}) ([]cue.ValidationError, error) {
35+
return validator.ValidateAgent(data)
36+
}
37+
38+
func (l *AgentLinter) ValidateSpecific(data map[string]interface{}, filePath, contents string) []cue.ValidationError {
39+
errors := validateAgentSpecific(data, filePath, contents)
40+
41+
// Validate allowed-tools
42+
toolWarnings := ValidateAllowedTools(data, filePath, contents)
43+
for _, w := range toolWarnings {
44+
errors = append(errors, w)
45+
}
46+
47+
return errors
48+
}
49+
50+
func (l *AgentLinter) ValidateBestPractices(filePath, contents string, data map[string]interface{}) []cue.ValidationError {
51+
// Best practices are called within validateAgentSpecific
52+
return nil
53+
}
54+
55+
func (l *AgentLinter) ValidateCrossFile(crossValidator *CrossFileValidator, filePath, contents string, data map[string]interface{}) []cue.ValidationError {
56+
if crossValidator == nil {
57+
return nil
58+
}
59+
return crossValidator.ValidateAgent(filePath, contents)
60+
}
61+
62+
func (l *AgentLinter) Score(contents string, data map[string]interface{}, body string) *scoring.QualityScore {
63+
scorer := scoring.NewAgentScorer()
64+
score := scorer.Score(contents, data, body)
65+
return &score
66+
}
67+
68+
func (l *AgentLinter) GetImprovements(contents string, data map[string]interface{}) []ImprovementRecommendation {
69+
return GetAgentImprovements(contents, data)
70+
}
71+
72+
// PostProcessBatch implements BatchPostProcessor for cycle detection.
73+
func (l *AgentLinter) PostProcessBatch(ctx *LinterContext, summary *LintSummary) {
74+
if ctx.NoCycleCheck {
75+
return
76+
}
77+
78+
cycles := ctx.CrossValidator.DetectCycles()
79+
cyclesReported := make(map[string]bool)
80+
81+
for _, cycle := range cycles {
82+
cycleDesc := FormatCycle(cycle)
83+
if cyclesReported[cycleDesc] {
84+
continue
85+
}
86+
cyclesReported[cycleDesc] = true
87+
88+
// Find agents involved in the cycle
89+
agentsInCycle := make(map[string]bool)
90+
for _, node := range cycle.Path {
91+
parts := strings.SplitN(node, ":", 2)
92+
if len(parts) == 2 && parts[0] == "agent" {
93+
agentsInCycle[parts[1]] = true
94+
}
95+
}
96+
97+
// Report to each agent once
98+
for agentName := range agentsInCycle {
99+
for i, result := range summary.Results {
100+
resultName := crossExtractAgentName(result.File)
101+
if resultName == agentName {
102+
summary.Results[i].Errors = append(summary.Results[i].Errors, cue.ValidationError{
103+
File: result.File,
104+
Message: fmt.Sprintf("Circular dependency detected: %s", cycleDesc),
105+
Severity: "error",
106+
Source: cue.SourceCClintObserve,
107+
})
108+
summary.TotalErrors++
109+
if summary.Results[i].Success {
110+
summary.Results[i].Success = false
111+
summary.SuccessfulFiles--
112+
summary.FailedFiles++
113+
}
114+
break
115+
}
116+
}
117+
}
118+
}
119+
}

internal/cli/agents.go

Lines changed: 7 additions & 157 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@ import (
77
"time"
88

99
"github.com/dotcommander/cclint/internal/cue"
10-
"github.com/dotcommander/cclint/internal/discovery"
11-
"github.com/dotcommander/cclint/internal/frontend"
1210
"github.com/dotcommander/cclint/internal/scoring"
1311
)
1412

@@ -39,147 +37,13 @@ type LintSummary struct {
3937
Results []LintResult
4038
}
4139

42-
// LintAgents runs linting on agent files
40+
// LintAgents runs linting on agent files using the generic linter.
4341
func LintAgents(rootPath string, quiet bool, verbose bool, noCycleCheck bool) (*LintSummary, error) {
44-
// Initialize shared context
4542
ctx, err := NewLinterContext(rootPath, quiet, verbose, noCycleCheck)
4643
if err != nil {
4744
return nil, err
4845
}
49-
50-
// Filter agent files
51-
agentFiles := ctx.FilterFilesByType(discovery.FileTypeAgent)
52-
summary := ctx.NewSummary(len(agentFiles))
53-
54-
// Process each agent file
55-
for _, file := range agentFiles {
56-
result := LintResult{
57-
File: file.RelPath,
58-
Type: "agent",
59-
Success: true,
60-
}
61-
62-
// Parse frontmatter
63-
fm, err := frontend.ParseYAMLFrontmatter(file.Contents)
64-
if err != nil {
65-
result.Errors = append(result.Errors, cue.ValidationError{
66-
File: file.RelPath,
67-
Message: fmt.Sprintf("Error parsing frontmatter: %v", err),
68-
Severity: "error",
69-
})
70-
result.Success = false
71-
summary.FailedFiles++
72-
summary.TotalErrors++
73-
} else {
74-
// Validate with CUE
75-
if true { // CUE schemas not loaded yet
76-
errors, err := ctx.Validator.ValidateAgent(fm.Data)
77-
if err != nil {
78-
result.Errors = append(result.Errors, cue.ValidationError{
79-
File: file.RelPath,
80-
Message: fmt.Sprintf("Validation error: %v", err),
81-
Severity: "error",
82-
})
83-
}
84-
result.Errors = append(result.Errors, errors...)
85-
summary.TotalErrors += len(errors)
86-
}
87-
88-
// Additional validation rules - separate errors and suggestions
89-
allIssues := validateAgentSpecific(fm.Data, file.RelPath, file.Contents)
90-
for _, issue := range allIssues {
91-
if issue.Severity == "suggestion" {
92-
result.Suggestions = append(result.Suggestions, issue)
93-
summary.TotalSuggestions++
94-
} else {
95-
result.Errors = append(result.Errors, issue)
96-
summary.TotalErrors++
97-
}
98-
}
99-
100-
// Validate allowed-tools field
101-
toolWarnings := ValidateAllowedTools(fm.Data, file.RelPath, file.Contents)
102-
result.Warnings = append(result.Warnings, toolWarnings...)
103-
summary.TotalWarnings += len(toolWarnings)
104-
105-
// Cross-file validation (missing skills)
106-
crossErrors := ctx.CrossValidator.ValidateAgent(file.RelPath, file.Contents)
107-
result.Errors = append(result.Errors, crossErrors...)
108-
summary.TotalErrors += len(crossErrors)
109-
110-
// Secrets detection
111-
secretWarnings := detectSecrets(file.Contents, file.RelPath)
112-
result.Warnings = append(result.Warnings, secretWarnings...)
113-
summary.TotalWarnings += len(secretWarnings)
114-
115-
if len(result.Errors) == 0 {
116-
summary.SuccessfulFiles++
117-
} else {
118-
result.Success = false
119-
summary.FailedFiles++
120-
}
121-
122-
// Score agent quality
123-
scorer := scoring.NewAgentScorer()
124-
score := scorer.Score(file.Contents, fm.Data, fm.Body)
125-
result.Quality = &score
126-
127-
// Get improvement recommendations
128-
result.Improvements = GetAgentImprovements(file.Contents, fm.Data)
129-
}
130-
131-
summary.Results = append(summary.Results, result)
132-
ctx.LogProcessed(file.RelPath, len(result.Errors))
133-
}
134-
135-
// Detect circular dependencies (unless disabled)
136-
if !ctx.NoCycleCheck {
137-
cycles := ctx.CrossValidator.DetectCycles()
138-
// Track which agents have been reported to avoid duplicates
139-
cyclesReported := make(map[string]bool)
140-
for _, cycle := range cycles {
141-
cycleDesc := FormatCycle(cycle)
142-
// Only report each unique cycle once
143-
if cyclesReported[cycleDesc] {
144-
continue
145-
}
146-
cyclesReported[cycleDesc] = true
147-
148-
// Add cycle error to all agent files involved in the cycle
149-
agentsInCycle := make(map[string]bool)
150-
for _, node := range cycle.Path {
151-
parts := strings.SplitN(node, ":", 2)
152-
if len(parts) == 2 && parts[0] == "agent" {
153-
agentsInCycle[parts[1]] = true
154-
}
155-
}
156-
157-
// Report to each agent once
158-
for agentName := range agentsInCycle {
159-
for i, result := range summary.Results {
160-
resultName := crossExtractAgentName(result.File)
161-
if resultName == agentName {
162-
summary.Results[i].Errors = append(summary.Results[i].Errors, cue.ValidationError{
163-
File: result.File,
164-
Message: fmt.Sprintf("Circular dependency detected: %s", cycleDesc),
165-
Severity: "error",
166-
Source: cue.SourceCClintObserve,
167-
})
168-
summary.TotalErrors++
169-
// Mark file as failed
170-
if summary.Results[i].Success {
171-
summary.Results[i].Success = false
172-
summary.SuccessfulFiles--
173-
summary.FailedFiles++
174-
}
175-
break
176-
}
177-
}
178-
}
179-
}
180-
}
181-
182-
return summary, nil
46+
return lintBatch(ctx, NewAgentLinter()), nil
18347
}
18448

18549
// validateAgentSpecific implements agent-specific validation rules
@@ -300,29 +164,15 @@ func validateAgentBestPractices(filePath string, contents string, data map[strin
300164
fmEndLine := GetFrontmatterEndLine(contents)
301165

302166
// XML tag detection in text fields - FROM ANTHROPIC DOCS
303-
xmlTagPattern := regexp.MustCompile(`<[a-zA-Z][^>]*>`)
304167
if description, ok := data["description"].(string); ok {
305-
if xmlTagPattern.MatchString(description) {
306-
suggestions = append(suggestions, cue.ValidationError{
307-
File: filePath,
308-
Message: "Description contains XML-like tags which are not allowed",
309-
Severity: "error",
310-
Source: cue.SourceAnthropicDocs,
311-
Line: FindFrontmatterFieldLine(contents, "description"),
312-
})
168+
if xmlErr := DetectXMLTags(description, "Description", filePath, contents); xmlErr != nil {
169+
suggestions = append(suggestions, *xmlErr)
313170
}
314171
}
315172

316-
// Count total lines (±10% tolerance: 200 base + 20 = 220)
317-
lines := strings.Count(contents, "\n")
318-
if lines > 220 {
319-
suggestions = append(suggestions, cue.ValidationError{
320-
File: filePath,
321-
Message: fmt.Sprintf("Agent is %d lines. Best practice: keep agents under ~220 lines (200±10%%) - move methodology to skills instead.", lines),
322-
Severity: "suggestion",
323-
Source: cue.SourceCClintObserve,
324-
Line: 1,
325-
})
173+
// Count total lines (±10% tolerance: 200 base)
174+
if sizeErr := CheckSizeLimit(contents, 200, 0.10, "agent", filePath); sizeErr != nil {
175+
suggestions = append(suggestions, *sizeErr)
326176
}
327177

328178
// === BLOAT SECTIONS DETECTOR ===

internal/cli/command_linter.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package cli
2+
3+
import (
4+
"github.com/dotcommander/cclint/internal/cue"
5+
"github.com/dotcommander/cclint/internal/discovery"
6+
"github.com/dotcommander/cclint/internal/scoring"
7+
)
8+
9+
// CommandLinter implements ComponentLinter for command files.
10+
type CommandLinter struct {
11+
BaseLinter
12+
}
13+
14+
// NewCommandLinter creates a new CommandLinter.
15+
func NewCommandLinter() *CommandLinter {
16+
return &CommandLinter{}
17+
}
18+
19+
func (l *CommandLinter) Type() string {
20+
return "command"
21+
}
22+
23+
func (l *CommandLinter) FileType() discovery.FileType {
24+
return discovery.FileTypeCommand
25+
}
26+
27+
func (l *CommandLinter) ParseContent(contents string) (map[string]interface{}, string, error) {
28+
return parseFrontmatter(contents)
29+
}
30+
31+
func (l *CommandLinter) ValidateCUE(validator *cue.Validator, data map[string]interface{}) ([]cue.ValidationError, error) {
32+
return validator.ValidateCommand(data)
33+
}
34+
35+
func (l *CommandLinter) ValidateSpecific(data map[string]interface{}, filePath, contents string) []cue.ValidationError {
36+
errors := validateCommandSpecific(data, filePath, contents)
37+
38+
// Validate allowed-tools
39+
toolWarnings := ValidateAllowedTools(data, filePath, contents)
40+
for _, w := range toolWarnings {
41+
errors = append(errors, w)
42+
}
43+
44+
return errors
45+
}
46+
47+
func (l *CommandLinter) ValidateBestPractices(filePath, contents string, data map[string]interface{}) []cue.ValidationError {
48+
return validateCommandBestPractices(filePath, contents, data)
49+
}
50+
51+
func (l *CommandLinter) ValidateCrossFile(crossValidator *CrossFileValidator, filePath, contents string, data map[string]interface{}) []cue.ValidationError {
52+
if crossValidator == nil {
53+
return nil
54+
}
55+
return crossValidator.ValidateCommand(filePath, contents, data)
56+
}
57+
58+
func (l *CommandLinter) Score(contents string, data map[string]interface{}, body string) *scoring.QualityScore {
59+
scorer := scoring.NewCommandScorer()
60+
score := scorer.Score(contents, data, body)
61+
return &score
62+
}
63+
64+
func (l *CommandLinter) GetImprovements(contents string, data map[string]interface{}) []ImprovementRecommendation {
65+
return GetCommandImprovements(contents, data)
66+
}

0 commit comments

Comments
 (0)