Skip to content

Commit 319fae2

Browse files
Add stylistic lint checks to skill validate (#42) (#62)
* Add stylistic lint checks to skill validate (#42) - Add SeverityHint for non-blocking style suggestions - Add lintStyle() with 3 checks: body too short, description too vague, body lacks step structure - Track hints separately from errors/warnings in CLI output - Add ~ prefix for hint display in text output Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add CLI-level tests for hint output in skill validate Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 438ecc0 commit 319fae2

File tree

5 files changed

+155
-9
lines changed

5 files changed

+155
-9
lines changed

internal/cli/skill_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,35 @@ func TestSkillValidate_Text(t *testing.T) {
323323
assert.Contains(t, out, "valid")
324324
}
325325

326+
func TestSkillValidate_HintsJSON(t *testing.T) {
327+
cc := testRegistry(t)
328+
329+
// Default body is short (~8 words) — triggers body-too-short hint
330+
_, err := runCmd(t, cc, "skill", "create", "hint-skill", "--description", "A test skill", "--author", "alice")
331+
require.NoError(t, err)
332+
333+
out, err := runCmd(t, cc, "skill", "validate", "hint-skill", "--json")
334+
require.NoError(t, err)
335+
336+
var result output.SkillValidateResult
337+
require.NoError(t, json.Unmarshal([]byte(out), &result))
338+
assert.True(t, result.Valid, "hints should not make skill invalid")
339+
assert.Equal(t, 0, result.Errors)
340+
assert.Greater(t, result.Hints, 0, "should have at least one hint")
341+
}
342+
343+
func TestSkillValidate_HintsText(t *testing.T) {
344+
cc := testRegistry(t)
345+
346+
_, err := runCmd(t, cc, "skill", "create", "hint-text", "--description", "A test skill", "--author", "alice")
347+
require.NoError(t, err)
348+
349+
out, err := runCmd(t, cc, "skill", "validate", "hint-text")
350+
require.NoError(t, err)
351+
assert.Contains(t, out, "hint(s)")
352+
assert.Contains(t, out, "~")
353+
}
354+
326355
// --- skill create with overlap ---
327356

328357
func TestSkillCreate_OverlapWarn(t *testing.T) {

internal/cli/skill_validate.go

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,17 +52,21 @@ func toValidateResult(name string, issues []skill.ValidationIssue) output.SkillV
5252
var issueResults []output.ValidationIssueResult
5353
errors := 0
5454
warns := 0
55+
hints := 0
5556

5657
for _, issue := range issues {
5758
issueResults = append(issueResults, output.ValidationIssueResult{
5859
Field: issue.Field,
5960
Severity: string(issue.Severity),
6061
Message: issue.Message,
6162
})
62-
if issue.Severity == skill.SeverityError {
63+
switch issue.Severity {
64+
case skill.SeverityError:
6365
errors++
64-
} else {
66+
case skill.SeverityWarning:
6567
warns++
68+
case skill.SeverityHint:
69+
hints++
6670
}
6771
}
6872

@@ -72,6 +76,7 @@ func toValidateResult(name string, issues []skill.ValidationIssue) output.SkillV
7276
Issues: issueResults,
7377
Errors: errors,
7478
Warns: warns,
79+
Hints: hints,
7580
}
7681
}
7782

@@ -83,16 +88,21 @@ func formatValidateResult(r output.SkillValidateResult) string {
8388
return b.String()
8489
}
8590

86-
if r.Valid {
87-
fmt.Fprintf(&b, "Skill %q is valid with %d warning(s):\n", r.Name, r.Warns)
91+
if r.Valid && r.Warns == 0 && r.Hints > 0 {
92+
fmt.Fprintf(&b, "Skill %q is valid with %d hint(s):\n", r.Name, r.Hints)
93+
} else if r.Valid {
94+
fmt.Fprintf(&b, "Skill %q is valid with %d warning(s), %d hint(s):\n", r.Name, r.Warns, r.Hints)
8895
} else {
89-
fmt.Fprintf(&b, "Skill %q has %d error(s) and %d warning(s):\n", r.Name, r.Errors, r.Warns)
96+
fmt.Fprintf(&b, "Skill %q has %d error(s), %d warning(s), %d hint(s):\n", r.Name, r.Errors, r.Warns, r.Hints)
9097
}
9198

9299
for _, issue := range r.Issues {
93100
prefix := " ✗"
94-
if issue.Severity == "warning" {
101+
switch issue.Severity {
102+
case "warning":
95103
prefix = " !"
104+
case "hint":
105+
prefix = " ~"
96106
}
97107
fmt.Fprintf(&b, "%s %s: %s\n", prefix, issue.Field, issue.Message)
98108
}

internal/output/types_skill.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,4 +96,5 @@ type SkillValidateResult struct {
9696
Issues []ValidationIssueResult `json:"issues"`
9797
Errors int `json:"errors"`
9898
Warns int `json:"warnings"`
99+
Hints int `json:"hints"`
99100
}

internal/skill/validator.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ type Severity string
1515
const (
1616
SeverityError Severity = "error"
1717
SeverityWarning Severity = "warning"
18+
SeverityHint Severity = "hint"
1819
)
1920

2021
// ValidationIssue represents a single validation problem found in a skill.
@@ -37,6 +38,7 @@ func Validate(s *Skill) []ValidationIssue {
3738
issues = append(issues, validateBody(s.Body)...)
3839
issues = append(issues, validateAllowedTools(s.AllowedTools)...)
3940
issues = append(issues, validateMetadata(s.Metadata)...)
41+
issues = append(issues, lintStyle(s)...)
4042

4143
return issues
4244
}
@@ -131,6 +133,54 @@ func ValidateFolder(s *Skill, skillDir string) []ValidationIssue {
131133
return issues
132134
}
133135

136+
// Stylistic lint thresholds.
137+
const (
138+
lintBodyMinWords = 20
139+
lintDescMinWords = 3
140+
)
141+
142+
// lintStyle performs stylistic quality checks on a skill.
143+
// Issues use SeverityHint to distinguish from structural errors/warnings.
144+
func lintStyle(s *Skill) []ValidationIssue {
145+
var issues []ValidationIssue
146+
147+
// Body too short
148+
bodyWords := len(strings.Fields(strings.TrimSpace(s.Body)))
149+
if bodyWords > 0 && bodyWords < lintBodyMinWords {
150+
issues = append(issues, ValidationIssue{
151+
Field: "body",
152+
Severity: SeverityHint,
153+
Message: fmt.Sprintf("body has only %d words; consider adding more detailed instructions", bodyWords),
154+
})
155+
}
156+
157+
// Description too vague (very short)
158+
descWords := len(strings.Fields(strings.TrimSpace(s.Description)))
159+
if descWords > 0 && descWords < lintDescMinWords {
160+
issues = append(issues, ValidationIssue{
161+
Field: "description",
162+
Severity: SeverityHint,
163+
Message: fmt.Sprintf("description has only %d word(s); consider being more specific", descWords),
164+
})
165+
}
166+
167+
// Body lacks step-by-step guidance markers
168+
bodyLower := strings.ToLower(s.Body)
169+
hasSteps := strings.Contains(bodyLower, "step") ||
170+
strings.Contains(bodyLower, "1.") ||
171+
strings.Contains(bodyLower, "- ") ||
172+
strings.Contains(bodyLower, "* ")
173+
if bodyWords >= lintBodyMinWords && !hasSteps {
174+
issues = append(issues, ValidationIssue{
175+
Field: "body",
176+
Severity: SeverityHint,
177+
Message: "body lacks step-by-step structure; consider adding numbered steps or bullet points",
178+
})
179+
}
180+
181+
return issues
182+
}
183+
134184
var semverRegex = regexp.MustCompile(`^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$`)
135185

136186
func validateMetadata(m Metadata) []ValidationIssue {

internal/skill/validator_test.go

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ func TestValidate_ValidSkill(t *testing.T) {
1212
s := &Skill{
1313
Name: "my-skill",
1414
Description: "A valid skill description",
15-
Body: "## Instructions\n\nDo something useful.",
15+
Body: "## Instructions\n\nThis skill provides step-by-step guidance for completing tasks:\n\n- First, analyze the input carefully and validate the data\n- Then, process the data accordingly and format the results\n- Finally, return the formatted output to the user",
1616
Metadata: Metadata{
1717
Author: Author{Name: "alice", Type: "human"},
1818
Version: "1.0.0",
@@ -100,8 +100,8 @@ func TestValidate_Description1024OK(t *testing.T) {
100100

101101
issues := Validate(s)
102102
for _, i := range issues {
103-
if i.Field == "description" {
104-
t.Errorf("unexpected description issue: %s", i.Message)
103+
if i.Field == "description" && i.Severity == SeverityError {
104+
t.Errorf("unexpected description error: %s", i.Message)
105105
}
106106
}
107107
}
@@ -242,6 +242,7 @@ func TestHasErrors(t *testing.T) {
242242
}{
243243
{"no issues", nil, false},
244244
{"warnings only", []ValidationIssue{{Severity: SeverityWarning}}, false},
245+
{"hints only", []ValidationIssue{{Severity: SeverityHint}}, false},
245246
{"has error", []ValidationIssue{{Severity: SeverityError}}, true},
246247
{"mixed", []ValidationIssue{{Severity: SeverityWarning}, {Severity: SeverityError}}, true},
247248
}
@@ -261,3 +262,58 @@ func TestValidationIssue_String(t *testing.T) {
261262
}
262263
assert.Equal(t, "[error] name: name is invalid", issue.String())
263264
}
265+
266+
// Lint style tests
267+
268+
func TestLintStyle_BodyTooShort(t *testing.T) {
269+
s := &Skill{
270+
Name: "my-skill",
271+
Description: "A valid skill description",
272+
Body: "## Instructions\n\nDo something useful.",
273+
}
274+
275+
issues := lintStyle(s)
276+
require.Len(t, issues, 1)
277+
assert.Equal(t, "body", issues[0].Field)
278+
assert.Equal(t, SeverityHint, issues[0].Severity)
279+
assert.Contains(t, issues[0].Message, "words")
280+
}
281+
282+
func TestLintStyle_DescriptionTooVague(t *testing.T) {
283+
s := &Skill{
284+
Name: "my-skill",
285+
Description: "Deploy",
286+
Body: "## Instructions\n\nThis skill provides step-by-step guidance for completing tasks:\n\n- First, analyze the input carefully and validate the data\n- Then, process the data accordingly and format the results\n- Finally, return the formatted output to the user",
287+
}
288+
289+
issues := lintStyle(s)
290+
require.Len(t, issues, 1)
291+
assert.Equal(t, "description", issues[0].Field)
292+
assert.Equal(t, SeverityHint, issues[0].Severity)
293+
assert.Contains(t, issues[0].Message, "word")
294+
}
295+
296+
func TestLintStyle_BodyLacksSteps(t *testing.T) {
297+
s := &Skill{
298+
Name: "my-skill",
299+
Description: "A valid skill description",
300+
Body: strings.Repeat("word ", 25),
301+
}
302+
303+
issues := lintStyle(s)
304+
require.Len(t, issues, 1)
305+
assert.Equal(t, "body", issues[0].Field)
306+
assert.Equal(t, SeverityHint, issues[0].Severity)
307+
assert.Contains(t, issues[0].Message, "step-by-step")
308+
}
309+
310+
func TestLintStyle_NoHints(t *testing.T) {
311+
s := &Skill{
312+
Name: "my-skill",
313+
Description: "A valid skill description",
314+
Body: "## Instructions\n\nThis skill provides step-by-step guidance for completing tasks:\n\n- First, analyze the input carefully and validate the data\n- Then, process the data accordingly and format the results\n- Finally, return the formatted output to the user",
315+
}
316+
317+
issues := lintStyle(s)
318+
assert.Empty(t, issues)
319+
}

0 commit comments

Comments
 (0)