Skip to content

Commit 9568fcf

Browse files
committed
feat: add Go guide lint CLI
1 parent 519d6e7 commit 9568fcf

2 files changed

Lines changed: 474 additions & 0 deletions

File tree

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"io"
7+
"os"
8+
"path/filepath"
9+
10+
"github.com/speakeasy-api/mcp-setup-docs/tools/lint-guide/internal/guidecheck"
11+
)
12+
13+
const usageText = "Usage: npm run lint-guide -- [--json] <slug|guides/<slug>|path>…"
14+
15+
type guideResult struct {
16+
Guide string `json:"guide"`
17+
Findings []guidecheck.Finding `json:"findings"`
18+
}
19+
20+
type options struct {
21+
jsonOutput bool
22+
targets []string
23+
}
24+
25+
func main() {
26+
cwd, err := os.Getwd()
27+
if err != nil {
28+
fmt.Fprintln(os.Stderr, err)
29+
os.Exit(2)
30+
}
31+
os.Exit(execute(os.Args[1:], cwd, os.Stdout, os.Stderr))
32+
}
33+
34+
func execute(args []string, cwd string, stdout, stderr io.Writer) int {
35+
opts, terminalUsage := parseArgs(args)
36+
if terminalUsage {
37+
fmt.Fprintln(stderr, usageText)
38+
return 2
39+
}
40+
repoRoot, err := findRepoRoot(cwd)
41+
if err != nil {
42+
fmt.Fprintln(stderr, err)
43+
return 2
44+
}
45+
return runOptions(opts, cwd, repoRoot, stdout, stderr)
46+
}
47+
48+
func run(args []string, cwd, repoRoot string, stdout, stderr io.Writer) int {
49+
opts, terminalUsage := parseArgs(args)
50+
if terminalUsage {
51+
fmt.Fprintln(stderr, usageText)
52+
return 2
53+
}
54+
return runOptions(opts, cwd, repoRoot, stdout, stderr)
55+
}
56+
57+
func parseArgs(args []string) (options, bool) {
58+
opts := options{targets: make([]string, 0, len(args))}
59+
for _, arg := range args {
60+
switch arg {
61+
case "--json":
62+
opts.jsonOutput = true
63+
case "--help", "-h":
64+
return options{}, true
65+
default:
66+
opts.targets = append(opts.targets, arg)
67+
}
68+
}
69+
return opts, len(opts.targets) == 0
70+
}
71+
72+
func runOptions(opts options, cwd, repoRoot string, stdout, stderr io.Writer) int {
73+
jsonOutput := opts.jsonOutput
74+
targets := opts.targets
75+
76+
dirs := make([]string, 0, len(targets))
77+
for _, target := range targets {
78+
dir, err := resolveGuideDir(target, cwd, repoRoot)
79+
if err != nil {
80+
fmt.Fprintln(stderr, err)
81+
return 2
82+
}
83+
dirs = append(dirs, dir)
84+
}
85+
86+
results := make([]guideResult, 0, len(dirs))
87+
total := 0
88+
for _, dir := range dirs {
89+
findings, err := guidecheck.CheckGuide(dir, repoRoot)
90+
if err != nil {
91+
fmt.Fprintln(stderr, err)
92+
return 2
93+
}
94+
total += len(findings)
95+
results = append(results, guideResult{Guide: dir, Findings: findings})
96+
}
97+
98+
if jsonOutput {
99+
encoder := json.NewEncoder(stdout)
100+
encoder.SetIndent("", " ")
101+
if err := encoder.Encode(results); err != nil {
102+
reportWriteError(stderr, err)
103+
return 2
104+
}
105+
} else if err := writeHuman(stdout, results); err != nil {
106+
reportWriteError(stderr, err)
107+
return 2
108+
}
109+
if total > 0 {
110+
return 1
111+
}
112+
return 0
113+
}
114+
115+
func writeHuman(w io.Writer, results []guideResult) error {
116+
for _, result := range results {
117+
slug := filepath.Base(result.Guide)
118+
if len(result.Findings) == 0 {
119+
if _, err := fmt.Fprintf(w, "%s: ok\n", slug); err != nil {
120+
return err
121+
}
122+
continue
123+
}
124+
if _, err := fmt.Fprintf(w, "%s: %d finding(s)\n", slug, len(result.Findings)); err != nil {
125+
return err
126+
}
127+
for _, finding := range result.Findings {
128+
label := finding.Severity
129+
if finding.Rule != "" {
130+
label += "/" + finding.Rule
131+
}
132+
if _, err := fmt.Fprintf(w, " [%s] %s %s: %s\n", label, finding.Target, finding.Where, finding.Problem); err != nil {
133+
return err
134+
}
135+
if _, err := fmt.Fprintf(w, " → %s\n", finding.Suggestion); err != nil {
136+
return err
137+
}
138+
}
139+
}
140+
return nil
141+
}
142+
143+
func reportWriteError(stderr io.Writer, err error) {
144+
fmt.Fprintf(stderr, "write output: %v\n", err)
145+
}
146+
147+
func resolveGuideDir(arg, cwd, repoRoot string) (string, error) {
148+
cwdCandidate := arg
149+
if !filepath.IsAbs(cwdCandidate) {
150+
cwdCandidate = filepath.Join(cwd, cwdCandidate)
151+
}
152+
if pathExists(filepath.Join(cwdCandidate, "external.md")) || pathExists(filepath.Join(cwdCandidate, "meta.yaml")) {
153+
return filepath.Abs(cwdCandidate)
154+
}
155+
156+
for _, candidate := range []string{
157+
filepath.Join(repoRoot, "guides", arg),
158+
filepath.Join(repoRoot, arg),
159+
} {
160+
if pathExists(candidate) {
161+
return filepath.Abs(candidate)
162+
}
163+
}
164+
return "", fmt.Errorf("No guide directory for %q", arg)
165+
}
166+
167+
func findRepoRoot(start string) (string, error) {
168+
dir, err := filepath.Abs(start)
169+
if err != nil {
170+
return "", err
171+
}
172+
for {
173+
if pathExists(filepath.Join(dir, "schema", "guide.v1.schema.json")) {
174+
return dir, nil
175+
}
176+
parent := filepath.Dir(dir)
177+
if parent == dir {
178+
return "", fmt.Errorf("repository root not found from %q", start)
179+
}
180+
dir = parent
181+
}
182+
}
183+
184+
func pathExists(path string) bool {
185+
_, err := os.Stat(path)
186+
return err == nil
187+
}

0 commit comments

Comments
 (0)