Skip to content

Commit 0404e01

Browse files
committed
feat: add compact output format
The default text output prints every passing check, the token tables, and the analysis sections for each skill. Validating a directory of skills produces hundreds of lines in which the few findings that need attention are buried. Add -o compact, which renders each skill as a single line naming it and its outcome, with any warnings and errors listed beneath it. Passing and informational findings, token counts, and the content and contamination analysis sections are omitted. Only the rendering changes: finding counts, the overall summary, and exit codes are still computed from the full report, and the other output formats are untouched.
1 parent 7480766 commit 0404e01

7 files changed

Lines changed: 316 additions & 3 deletions

File tree

README.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Spec compliance is table stakes. `skill-validator` goes further: it checks that
2424
- [score evaluate](#score-evaluate)
2525
- [score report](#score-report)
2626
- [Output Formats](#output-formats)
27+
- [Compact output](#compact-output)
2728
- [JSON output](#json-output)
2829
- [Markdown output](#markdown-output)
2930
- [GitHub Actions annotations](#github-actions-annotations)
@@ -435,7 +436,33 @@ The `--compare` flag is useful for understanding how different models perceive y
435436

436437
## Output Formats
437438

438-
All commands accept `-o text` (default), `-o json`, or `-o markdown` for output format. Use `--emit-annotations` with any format to emit GitHub Actions workflow annotations alongside normal output.
439+
All commands accept `-o text` (default), `-o compact`, `-o json`, or `-o markdown` for output format. Use `--emit-annotations` with any format to emit GitHub Actions workflow annotations alongside normal output.
440+
441+
### Compact output
442+
443+
The default text output prints every passing check, token counts, and the
444+
analysis sections for each skill, which is a lot to read when you only want to
445+
know what needs fixing. Use `-o compact` to reduce each skill to a single line,
446+
with its warnings and errors listed beneath it:
447+
448+
```
449+
skill-validator check -o compact <path>
450+
```
451+
452+
```
453+
⚠ skills/llm-wiki-base: 1 warning
454+
⚠ SKILL.md body is 5233 tokens (spec recommends < 5000)
455+
✓ skills/llm-wiki-capture: passed
456+
✓ skills/llm-wiki-distill: passed
457+
✓ skills/llm-wiki-retrieve: passed
458+
459+
4 skills validated: all passed
460+
Total: 1 warning
461+
```
462+
463+
Passing checks, informational findings, token counts, and the content and
464+
contamination analysis sections are omitted. Only the display changes: error and
465+
warning counts, the summary, and exit codes are identical to the full report.
439466

440467
### JSON output
441468

cmd/compact_integration_test.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package cmd_test
2+
3+
import (
4+
"os/exec"
5+
"strings"
6+
"testing"
7+
)
8+
9+
func TestCompactOutput(t *testing.T) {
10+
bin := buildBinary(t)
11+
12+
t.Run("passing skill is a single line", func(t *testing.T) {
13+
cmd := exec.Command(bin, "check", "-o", "compact", fixture(t, "valid-skill"))
14+
out, _ := cmd.CombinedOutput()
15+
s := string(out)
16+
17+
if got := cmd.ProcessState.ExitCode(); got != 0 {
18+
t.Errorf("exit code = %d, want 0\noutput: %s", got, s)
19+
}
20+
if got := strings.Count(strings.TrimRight(s, "\n"), "\n"); got != 0 {
21+
t.Errorf("expected a single line, got:\n%s", s)
22+
}
23+
if !strings.Contains(s, ": passed") {
24+
t.Errorf("expected a passed summary, got:\n%s", s)
25+
}
26+
})
27+
28+
t.Run("warnings are listed under the summary", func(t *testing.T) {
29+
cmd := exec.Command(bin, "check", "-o", "compact", fixture(t, "warnings-only-skill"))
30+
out, _ := cmd.CombinedOutput()
31+
s := string(out)
32+
33+
if got := cmd.ProcessState.ExitCode(); got != 2 {
34+
t.Errorf("exit code = %d, want 2\noutput: %s", got, s)
35+
}
36+
if !strings.Contains(s, "1 warning") {
37+
t.Errorf("expected the warning count on the summary line:\n%s", s)
38+
}
39+
if !strings.Contains(s, " ") || !strings.Contains(s, "unknown directory") {
40+
t.Errorf("expected the warning indented beneath the summary:\n%s", s)
41+
}
42+
if strings.Contains(s, "Validating skill") || strings.Contains(s, "Tokens") {
43+
t.Errorf("compact output should omit the full report sections:\n%s", s)
44+
}
45+
})
46+
47+
t.Run("errors are listed under the summary", func(t *testing.T) {
48+
cmd := exec.Command(bin, "check", "-o", "compact", fixture(t, "invalid-skill"))
49+
out, _ := cmd.CombinedOutput()
50+
s := string(out)
51+
52+
if got := cmd.ProcessState.ExitCode(); got != 1 {
53+
t.Errorf("exit code = %d, want 1\noutput: %s", got, s)
54+
}
55+
if !strings.Contains(s, "error") {
56+
t.Errorf("expected the error count on the summary line:\n%s", s)
57+
}
58+
})
59+
60+
t.Run("text output is unchanged", func(t *testing.T) {
61+
cmd := exec.Command(bin, "check", fixture(t, "warnings-only-skill"))
62+
out, _ := cmd.CombinedOutput()
63+
s := string(out)
64+
65+
if !strings.Contains(s, "Validating skill") || !strings.Contains(s, "Tokens") {
66+
t.Errorf("default text output should still be the full report:\n%s", s)
67+
}
68+
})
69+
}

cmd/root.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ var rootCmd = &cobra.Command{
3131

3232
func init() {
3333
rootCmd.Version = version
34-
rootCmd.PersistentFlags().StringVarP(&outputFormat, "output", "o", "text", "output format: text, json, or markdown")
34+
rootCmd.PersistentFlags().StringVarP(&outputFormat, "output", "o", "text", "output format: text, compact, json, or markdown")
3535
rootCmd.PersistentFlags().BoolVar(&emitAnnotations, "emit-annotations", false, "emit GitHub Actions workflow command annotations (::error/::warning) alongside normal output")
3636
}
3737

cmd/validate.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ func outputReportWithExitOpts(r *types.Report, perFile bool, opts exitOpts) erro
3838
if err := report.PrintMarkdown(os.Stdout, r, perFile); err != nil {
3939
return fmt.Errorf("writing markdown: %w", err)
4040
}
41+
case "compact":
42+
report.PrintCompact(os.Stdout, r)
4143
default:
4244
report.Print(os.Stdout, r, perFile)
4345
}
@@ -69,6 +71,8 @@ func outputMultiReportWithExitOpts(mr *types.MultiReport, perFile bool, opts exi
6971
if err := report.PrintMultiMarkdown(os.Stdout, mr, perFile); err != nil {
7072
return fmt.Errorf("writing markdown: %w", err)
7173
}
74+
case "compact":
75+
report.PrintMultiCompact(os.Stdout, mr)
7276
default:
7377
report.PrintMulti(os.Stdout, mr, perFile)
7478
}

report/compact.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package report
2+
3+
import (
4+
"fmt"
5+
"io"
6+
"strings"
7+
8+
"github.com/agent-ecosystem/skill-validator/types"
9+
"github.com/agent-ecosystem/skill-validator/util"
10+
)
11+
12+
// PrintCompact writes a one-line outcome for the skill. Warnings and errors are
13+
// listed underneath it; passing checks, token counts, and analysis sections are
14+
// omitted.
15+
func PrintCompact(w io.Writer, r *types.Report) {
16+
printCompactSkill(w, r)
17+
}
18+
19+
// PrintMultiCompact writes one compact entry per skill, followed by the overall
20+
// summary.
21+
func PrintMultiCompact(w io.Writer, mr *types.MultiReport) {
22+
for _, r := range mr.Skills {
23+
printCompactSkill(w, r)
24+
}
25+
printMultiSummary(w, mr)
26+
}
27+
28+
func printCompactSkill(w io.Writer, r *types.Report) {
29+
icon, color := compactLevel(r)
30+
_, _ = fmt.Fprintf(w, "%s%s %s: %s%s\n", color, icon, r.SkillDir, compactOutcome(r), colorReset)
31+
32+
for _, res := range r.Results {
33+
if res.Level < types.Warning {
34+
continue
35+
}
36+
resIcon, resColor := formatLevel(res.Level)
37+
_, _ = fmt.Fprintf(w, " %s%s %s%s\n", resColor, resIcon, res.Message, colorReset)
38+
}
39+
}
40+
41+
// compactLevel returns the icon and color representing the report's worst outcome.
42+
func compactLevel(r *types.Report) (string, string) {
43+
switch {
44+
case r.Errors > 0:
45+
return "✗", colorRed
46+
case r.Warnings > 0:
47+
return "⚠", colorYellow
48+
default:
49+
return "✓", colorGreen
50+
}
51+
}
52+
53+
// compactOutcome summarizes the report as "passed" or as its finding counts.
54+
func compactOutcome(r *types.Report) string {
55+
if r.Errors == 0 && r.Warnings == 0 {
56+
return "passed"
57+
}
58+
59+
parts := []string{}
60+
if r.Errors > 0 {
61+
parts = append(parts, fmt.Sprintf("%d error%s", r.Errors, util.PluralS(r.Errors)))
62+
}
63+
if r.Warnings > 0 {
64+
parts = append(parts, fmt.Sprintf("%d warning%s", r.Warnings, util.PluralS(r.Warnings)))
65+
}
66+
return strings.Join(parts, ", ")
67+
}

report/compact_test.go

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
package report
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/agent-ecosystem/skill-validator/types"
8+
)
9+
10+
func compactFixture(dir string, results ...types.Result) *types.Report {
11+
r := &types.Report{
12+
SkillDir: dir,
13+
Results: results,
14+
TokenCounts: []types.TokenCount{{File: "SKILL.md", Tokens: 100}},
15+
ContentReport: &types.ContentReport{WordCount: 10},
16+
ContaminationReport: &types.ContaminationReport{ContaminationLevel: "low"},
17+
}
18+
r.Tally()
19+
return r
20+
}
21+
22+
func TestPrintCompact_Passed(t *testing.T) {
23+
r := compactFixture("my-skill",
24+
types.Result{Level: types.Pass, Category: "Structure", Message: "SKILL.md found"},
25+
types.Result{Level: types.Info, Category: "Structure", Message: "fyi"},
26+
)
27+
28+
var buf strings.Builder
29+
PrintCompact(&buf, r)
30+
out := buf.String()
31+
32+
if !strings.Contains(out, "✓ my-skill: passed") {
33+
t.Errorf("expected one-line passed summary, got:\n%s", out)
34+
}
35+
if got := strings.Count(out, "\n"); got != 1 {
36+
t.Errorf("a passing skill should occupy one line, got %d:\n%s", got, out)
37+
}
38+
if strings.Contains(out, "SKILL.md found") || strings.Contains(out, "fyi") {
39+
t.Errorf("pass and info findings should be omitted:\n%s", out)
40+
}
41+
}
42+
43+
func TestPrintCompact_ListsWarningsAndErrors(t *testing.T) {
44+
r := compactFixture("my-skill",
45+
types.Result{Level: types.Pass, Category: "Structure", Message: "SKILL.md found"},
46+
types.Result{Level: types.Error, Category: "Links", Message: "broken link: ./gone.md"},
47+
types.Result{Level: types.Warning, Category: "Structure", Message: "unknown directory: extras/"},
48+
)
49+
50+
var buf strings.Builder
51+
PrintCompact(&buf, r)
52+
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
53+
54+
if len(lines) != 3 {
55+
t.Fatalf("expected a summary line plus two findings, got %d lines:\n%s", len(lines), buf.String())
56+
}
57+
if !strings.Contains(lines[0], "✗ my-skill: ") ||
58+
!strings.Contains(lines[0], "1 error") || !strings.Contains(lines[0], "1 warning") {
59+
t.Errorf("summary line should carry both counts, got: %s", lines[0])
60+
}
61+
for _, line := range lines[1:] {
62+
if !strings.HasPrefix(line, " ") {
63+
t.Errorf("findings should be indented under the summary, got: %q", line)
64+
}
65+
}
66+
if !strings.Contains(lines[1], "broken link: ./gone.md") {
67+
t.Errorf("expected the error message, got: %s", lines[1])
68+
}
69+
if !strings.Contains(lines[2], "unknown directory: extras/") {
70+
t.Errorf("expected the warning message, got: %s", lines[2])
71+
}
72+
if strings.Contains(buf.String(), "SKILL.md found") {
73+
t.Errorf("pass findings should be omitted:\n%s", buf.String())
74+
}
75+
}
76+
77+
func TestPrintCompact_OmitsAnalysisSections(t *testing.T) {
78+
r := compactFixture("my-skill",
79+
types.Result{Level: types.Warning, Category: "Structure", Message: "meh"},
80+
)
81+
r.OtherTokenCounts = []types.TokenCount{{File: "extra.md", Tokens: 50}}
82+
83+
var buf strings.Builder
84+
PrintCompact(&buf, r)
85+
out := buf.String()
86+
87+
for _, unwanted := range []string{"Tokens", "Content Analysis", "Contamination Analysis", "Validating skill"} {
88+
if strings.Contains(out, unwanted) {
89+
t.Errorf("compact output should omit %q:\n%s", unwanted, out)
90+
}
91+
}
92+
}
93+
94+
func TestPrintCompact_WarningOnlyUsesWarningIcon(t *testing.T) {
95+
r := compactFixture("my-skill",
96+
types.Result{Level: types.Warning, Category: "Structure", Message: "meh"},
97+
)
98+
99+
var buf strings.Builder
100+
PrintCompact(&buf, r)
101+
out := buf.String()
102+
103+
if !strings.Contains(out, "⚠ my-skill: 1 warning") {
104+
t.Errorf("expected warning icon and count, got:\n%s", out)
105+
}
106+
if strings.Contains(out, "✗") {
107+
t.Errorf("a warning-only report should not use the error icon:\n%s", out)
108+
}
109+
}
110+
111+
func TestPrintMultiCompact(t *testing.T) {
112+
mr := &types.MultiReport{
113+
Skills: []*types.Report{
114+
compactFixture("a"),
115+
compactFixture("b", types.Result{Level: types.Warning, Category: "Structure", Message: "meh"}),
116+
compactFixture("c"),
117+
},
118+
}
119+
for _, r := range mr.Skills {
120+
mr.Errors += r.Errors
121+
mr.Warnings += r.Warnings
122+
}
123+
124+
var buf strings.Builder
125+
PrintMultiCompact(&buf, mr)
126+
out := buf.String()
127+
128+
if !strings.Contains(out, "✓ a: passed") || !strings.Contains(out, "✓ c: passed") {
129+
t.Errorf("expected one-line summaries for passing skills, got:\n%s", out)
130+
}
131+
if !strings.Contains(out, "⚠ b: 1 warning") || !strings.Contains(out, " ") {
132+
t.Errorf("expected the failing skill's finding indented, got:\n%s", out)
133+
}
134+
if strings.Contains(out, strings.Repeat("━", 60)) {
135+
t.Errorf("compact output should not draw separators:\n%s", out)
136+
}
137+
if !strings.Contains(out, "3 skills validated") || !strings.Contains(out, "Total: ") {
138+
t.Errorf("expected the overall summary, got:\n%s", out)
139+
}
140+
}

report/report.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,13 @@ func PrintMulti(w io.Writer, mr *types.MultiReport, perFile bool) {
168168
Print(w, r, perFile)
169169
}
170170

171+
_, _ = fmt.Fprintf(w, "%s\n", strings.Repeat("━", 60))
172+
printMultiSummary(w, mr)
173+
}
174+
175+
// printMultiSummary writes the trailing per-skill tally and total counts shared
176+
// by the full and compact multi-skill renderers.
177+
func printMultiSummary(w io.Writer, mr *types.MultiReport) {
171178
passed := 0
172179
failed := 0
173180
for _, r := range mr.Skills {
@@ -178,7 +185,6 @@ func PrintMulti(w io.Writer, mr *types.MultiReport, perFile bool) {
178185
}
179186
}
180187

181-
_, _ = fmt.Fprintf(w, "%s\n", strings.Repeat("━", 60))
182188
_, _ = fmt.Fprintf(w, "\n%s%d skill%s validated: ", colorBold, len(mr.Skills), util.PluralS(len(mr.Skills)))
183189
if failed == 0 {
184190
_, _ = fmt.Fprintf(w, "%sall passed%s\n", colorGreen, colorReset)

0 commit comments

Comments
 (0)