v0.20.0 c741a39

check-docs.go RawHistoryFolder

1//go:build devtools
2// +build devtools
3
4package main
5
6import (
7	"bufio"
8	"bytes"
9	"errors"
10	"flag"
11	"fmt"
12	"go/ast"
13	"go/parser"
14	"go/token"
15	"io"
16	"os"
17	"path/filepath"
18	"regexp"
19	"sort"
20	"strings"
21	"time"
22)
23
24type issueLevel string
25
26const (
27	levelError   issueLevel = "ERROR"
28	levelWarning issueLevel = "WARNING"
29	levelInfo    issueLevel = "INFO"
30)
31
32type Issue struct {
33	Level   issueLevel
34	File    string
35	Line    int
36	Message string
37	Detail  string
38}
39
40type Report struct {
41	GeneratedAt time.Time
42	Issues      []Issue
43}
44
45func (r *Report) Add(level issueLevel, file string, line int, message, detail string) {
46	r.Issues = append(r.Issues, Issue{Level: level, File: file, Line: line, Message: message, Detail: detail})
47}
48
49func (r *Report) Count(level issueLevel) int {
50	count := 0
51	for _, it := range r.Issues {
52		if it.Level == level {
53			count++
54		}
55	}
56	return count
57}
58
59func main() {
60	failOnError := flag.Bool("fail-on-error", false, "exit non-zero if errors found")
61	out := flag.String("out", "dev/docs-report.txt", "write report to file (relative to repo root unless absolute)")
62	flag.Parse()
63
64	repoRoot, err := findRepoRoot()
65	if err != nil {
66		fmt.Fprintln(os.Stderr, err)
67		os.Exit(2)
68	}
69
70	report := &Report{GeneratedAt: time.Now()}
71
72	schemaKeys, err := extractConfigKeys(filepath.Join(repoRoot, "internal", "infrastructure", "config", "schema.go"))
73	if err != nil {
74		fmt.Fprintln(os.Stderr, "failed to parse config schema:", err)
75		os.Exit(2)
76	}
77
78	actionConstByName, err := extractActions(filepath.Join(repoRoot, "internal", "ui", "input", "shortcuts.go"))
79	if err != nil {
80		fmt.Fprintln(os.Stderr, "failed to parse shortcuts:", err)
81		os.Exit(2)
82	}
83
84	stubActionConstNames, err := extractStubbedActionConstNames(filepath.Join(repoRoot, "internal", "ui", "dispatcher", "keyboard.go"))
85	if err != nil {
86		fmt.Fprintln(os.Stderr, "failed to parse keyboard dispatcher:", err)
87		os.Exit(2)
88	}
89	stubActionValues := make(map[string]struct{}, len(stubActionConstNames))
90	for _, name := range stubActionConstNames {
91		if val, ok := actionConstByName[name]; ok {
92			stubActionValues[val] = struct{}{}
93		}
94	}
95
96	cliUses, err := extractCobraUseStrings(filepath.Join(repoRoot, "internal", "cli", "cmd"))
97	if err != nil {
98		fmt.Fprintln(os.Stderr, "failed to parse cli commands:", err)
99		os.Exit(2)
100	}
101
102	readmePath := filepath.Join(repoRoot, "README.md")
103	readmeLines, err := readLines(readmePath)
104	if err != nil {
105		fmt.Fprintln(os.Stderr, "failed to read README:", err)
106		os.Exit(2)
107	}
108	configDocPath := filepath.Join(repoRoot, "docs", "CONFIG.md")
109	configDocLines, err := readLines(configDocPath)
110	if err != nil {
111		fmt.Fprintln(os.Stderr, "failed to read docs/CONFIG.md:", err)
112		os.Exit(2)
113	}
114
115	verifyConfigDoc(report, schemaKeys, configDocPath, configDocLines)
116	verifyReadmeShortcuts(report, stubActionValues, readmePath, readmeLines)
117	verifyReadmeCommands(report, cliUses, readmePath, readmeLines)
118	verifyReadmeBuildClaims(report, readmePath, readmeLines)
119
120	reportPath := *out
121	if !filepath.IsAbs(reportPath) {
122		reportPath = filepath.Join(repoRoot, reportPath)
123	}
124	if err := writeReport(reportPath, report); err != nil {
125		fmt.Fprintln(os.Stderr, "failed to write report:", err)
126		os.Exit(2)
127	}
128
129	printSummary(report, reportPath)
130
131	if *failOnError && report.Count(levelError) > 0 {
132		os.Exit(1)
133	}
134}
135
136func findRepoRoot() (string, error) {
137	wd, err := os.Getwd()
138	if err != nil {
139		return "", err
140	}
141	dir := wd
142	for {
143		gitDir := filepath.Join(dir, ".git")
144		if st, err := os.Stat(gitDir); err == nil && st.IsDir() {
145			return dir, nil
146		}
147		parent := filepath.Dir(dir)
148		if parent == dir {
149			return "", errors.New("could not find repo root (.git)")
150		}
151		dir = parent
152	}
153}
154
155func readLines(path string) ([]string, error) {
156	f, err := os.Open(path)
157	if err != nil {
158		return nil, err
159	}
160	defer f.Close()
161
162	var lines []string
163	s := bufio.NewScanner(f)
164	for s.Scan() {
165		lines = append(lines, s.Text())
166	}
167	if err := s.Err(); err != nil {
168		return nil, err
169	}
170	return lines, nil
171}
172
173type schemaField struct {
174	Tag      string
175	TypeName string
176}
177
178func extractConfigKeys(schemaPath string) (map[string]struct{}, error) {
179	fset := token.NewFileSet()
180	file, err := parser.ParseFile(fset, schemaPath, nil, parser.ParseComments)
181	if err != nil {
182		return nil, err
183	}
184
185	typeFields := map[string][]schemaField{}
186	for _, decl := range file.Decls {
187		gen, ok := decl.(*ast.GenDecl)
188		if !ok || gen.Tok != token.TYPE {
189			continue
190		}
191		for _, spec := range gen.Specs {
192			ts, ok := spec.(*ast.TypeSpec)
193			if !ok {
194				continue
195			}
196			st, ok := ts.Type.(*ast.StructType)
197			if !ok {
198				continue
199			}
200			var fields []schemaField
201			for _, f := range st.Fields.List {
202				if len(f.Names) == 0 {
203					continue
204				}
205				tag := extractStructTagValue(f.Tag, "mapstructure")
206				if tag == "" || tag == "-" {
207					continue
208				}
209				fields = append(fields, schemaField{Tag: tag, TypeName: typeExprName(f.Type)})
210			}
211			typeFields[ts.Name.Name] = fields
212		}
213	}
214
215	keys := make(map[string]struct{})
216	seen := map[string]bool{}
217	var walkType func(typeName, prefix string)
218	walkType = func(typeName, prefix string) {
219		if seen[prefix+"::"+typeName] {
220			return
221		}
222		seen[prefix+"::"+typeName] = true
223
224		fields, ok := typeFields[typeName]
225		if !ok {
226			return
227		}
228		for _, f := range fields {
229			full := f.Tag
230			if prefix != "" {
231				full = prefix + "." + f.Tag
232			}
233			keys[full] = struct{}{}
234			if _, ok := typeFields[f.TypeName]; ok {
235				walkType(f.TypeName, full)
236			}
237		}
238	}
239
240	walkType("Config", "")
241	return keys, nil
242}
243
244func extractStructTagValue(tagLit *ast.BasicLit, key string) string {
245	if tagLit == nil {
246		return ""
247	}
248	tag := strings.Trim(tagLit.Value, "`")
249	parts := strings.Split(tag, " ")
250	for _, part := range parts {
251		kv := strings.SplitN(part, ":", 2)
252		if len(kv) != 2 {
253			continue
254		}
255		k := kv[0]
256		v := strings.Trim(kv[1], "\"")
257		if k != key {
258			continue
259		}
260		if idx := strings.IndexByte(v, ','); idx >= 0 {
261			return v[:idx]
262		}
263		return v
264	}
265	return ""
266}
267
268func typeExprName(expr ast.Expr) string {
269	switch t := expr.(type) {
270	case *ast.Ident:
271		return t.Name
272	case *ast.SelectorExpr:
273		// pkg.Type
274		return t.Sel.Name
275	case *ast.StarExpr:
276		return typeExprName(t.X)
277	default:
278		return ""
279	}
280}
281
282func extractActions(shortcutsPath string) (map[string]string, error) {
283	fset := token.NewFileSet()
284	file, err := parser.ParseFile(fset, shortcutsPath, nil, 0)
285	if err != nil {
286		return nil, err
287	}
288
289	out := map[string]string{}
290	for _, decl := range file.Decls {
291		gen, ok := decl.(*ast.GenDecl)
292		if !ok || gen.Tok != token.CONST {
293			continue
294		}
295		for _, spec := range gen.Specs {
296			vs, ok := spec.(*ast.ValueSpec)
297			if !ok {
298				continue
299			}
300			for i, name := range vs.Names {
301				if i >= len(vs.Values) {
302					continue
303				}
304				bl, ok := vs.Values[i].(*ast.BasicLit)
305				if !ok || bl.Kind != token.STRING {
306					continue
307				}
308				if strings.HasPrefix(name.Name, "Action") {
309					out[name.Name] = strings.Trim(bl.Value, "\"")
310				}
311			}
312		}
313	}
314	return out, nil
315}
316
317func extractStubbedActionConstNames(keyboardDispatcherPath string) ([]string, error) {
318	fset := token.NewFileSet()
319	file, err := parser.ParseFile(fset, keyboardDispatcherPath, nil, 0)
320	if err != nil {
321		return nil, err
322	}
323
324	stubbedSet := map[string]struct{}{}
325
326	ast.Inspect(file, func(n ast.Node) bool {
327		cl, ok := n.(*ast.CompositeLit)
328		if !ok {
329			return true
330		}
331		if _, ok := cl.Type.(*ast.MapType); !ok {
332			return true
333		}
334		for _, elt := range cl.Elts {
335			kv, ok := elt.(*ast.KeyValueExpr)
336			if !ok {
337				continue
338			}
339			keyName := selectorName(kv.Key)
340			if keyName == "" {
341				continue
342			}
343			fn, ok := kv.Value.(*ast.FuncLit)
344			if !ok {
345				continue
346			}
347			if funcCallsSelector(fn, "logNoop") {
348				stubbedSet[keyName] = struct{}{}
349			}
350		}
351		return true
352	})
353
354	var stubbed []string
355	for k := range stubbedSet {
356		stubbed = append(stubbed, k)
357	}
358	sort.Strings(stubbed)
359	return stubbed, nil
360}
361
362func selectorName(expr ast.Expr) string {
363	switch t := expr.(type) {
364	case *ast.SelectorExpr:
365		return t.Sel.Name
366	case *ast.Ident:
367		return t.Name
368	default:
369		return ""
370	}
371}
372
373func funcCallsSelector(fn *ast.FuncLit, selName string) bool {
374	if fn == nil || fn.Body == nil {
375		return false
376	}
377	found := false
378	ast.Inspect(fn.Body, func(n ast.Node) bool {
379		call, ok := n.(*ast.CallExpr)
380		if !ok {
381			return true
382		}
383		sel, ok := call.Fun.(*ast.SelectorExpr)
384		if !ok {
385			return true
386		}
387		if sel.Sel.Name == selName {
388			found = true
389			return false
390		}
391		return true
392	})
393	return found
394}
395
396func extractCobraUseStrings(cmdDir string) (map[string]struct{}, error) {
397	entries, err := os.ReadDir(cmdDir)
398	if err != nil {
399		return nil, err
400	}
401
402	uses := map[string]struct{}{}
403	useRe := regexp.MustCompile(`\bUse:\s*"([^"]+)"`)
404
405	for _, e := range entries {
406		if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") {
407			continue
408		}
409		b, err := os.ReadFile(filepath.Join(cmdDir, e.Name()))
410		if err != nil {
411			return nil, err
412		}
413		matches := useRe.FindAllSubmatch(b, -1)
414		for _, m := range matches {
415			use := string(m[1])
416			fields := strings.Fields(use)
417			if len(fields) == 0 {
418				continue
419			}
420			uses[fields[0]] = struct{}{}
421		}
422	}
423
424	return uses, nil
425}
426
427func verifyConfigDoc(report *Report, schemaKeys map[string]struct{}, configDocPath string, lines []string) {
428	docKeys := extractDocKeys(lines, schemaKeys)
429	for key, line := range docKeys {
430		if _, ok := schemaKeys[key]; ok {
431			continue
432		}
433		report.Add(levelWarning, rel(configDocPath), line, "config key documented but not present in schema", fmt.Sprintf("key: %s", key))
434	}
435}
436
437func extractDocKeys(lines []string, schemaKeys map[string]struct{}) map[string]int {
438	keyRe := regexp.MustCompile("`([a-zA-Z0-9_.-]+)`")
439	tableRowRe := regexp.MustCompile(`^\|`)
440	inCode := false
441
442	seen := map[string]int{}
443	for i, line := range lines {
444		trim := strings.TrimSpace(line)
445		if strings.HasPrefix(trim, "```") {
446			inCode = !inCode
447			continue
448		}
449		if inCode {
450			continue
451		}
452
453		// Prefer extracting keys from the first column of Markdown tables.
454		// This avoids false-positives from enum values shown in other columns.
455		if tableRowRe.MatchString(trim) {
456			// Skip table separator rows.
457			if strings.Contains(trim, "---") {
458				continue
459			}
460			parts := strings.Split(line, "|")
461			if len(parts) >= 3 {
462				firstCell := strings.TrimSpace(parts[1])
463				matches := keyRe.FindAllStringSubmatch(firstCell, -1)
464				for _, m := range matches {
465					k := m[1]
466					if k == "" || k[0] < 'a' || k[0] > 'z' {
467						continue
468					}
469					if strings.ContainsAny(k, " /") {
470						continue
471					}
472					if _, already := seen[k]; !already {
473						seen[k] = i + 1
474					}
475				}
476			}
477			continue
478		}
479
480		// Outside of tables, only accept dotted paths that look like config keys.
481		matches := keyRe.FindAllStringSubmatch(line, -1)
482		for _, m := range matches {
483			k := m[1]
484			if _, ok := schemaKeys[k]; ok {
485				if _, already := seen[k]; !already {
486					seen[k] = i + 1
487				}
488				continue
489			}
490			if !strings.Contains(k, ".") {
491				continue
492			}
493			if k == "" || k[0] < 'a' || k[0] > 'z' {
494				continue
495			}
496			if strings.ContainsAny(k, " /") {
497				continue
498			}
499			if _, already := seen[k]; !already {
500				seen[k] = i + 1
501			}
502		}
503	}
504	return seen
505}
506
507func verifyReadmeShortcuts(report *Report, stubActionValues map[string]struct{}, readmePath string, lines []string) {
508	stubToDocPatterns := map[string][]string{
509		"rename_tab":        {"Rename Tab"},
510		"toggle_fullscreen": {"Toggle Fullscreen"},
511	}
512	for action, patterns := range stubToDocPatterns {
513		if _, stubbed := stubActionValues[action]; !stubbed {
514			continue
515		}
516		for i, line := range lines {
517			for _, p := range patterns {
518				if strings.Contains(line, p) {
519					report.Add(levelError, rel(readmePath), i+1, "documented shortcut/feature appears stubbed in code", fmt.Sprintf("action: %s", action))
520					goto nextAction
521				}
522			}
523		}
524	nextAction:
525	}
526}
527
528func verifyReadmeCommands(report *Report, cliUses map[string]struct{}, readmePath string, lines []string) {
529	cmdRe := regexp.MustCompile("`dumber\\s+([^`]+)`")
530
531	for i, line := range lines {
532		matches := cmdRe.FindAllStringSubmatch(line, -1)
533		for _, m := range matches {
534			cmdLine := strings.TrimSpace(m[1])
535			fields := strings.Fields(cmdLine)
536			if len(fields) == 0 {
537				continue
538			}
539			cmd := fields[0]
540			// Ignore root flags like `dumber --dmenu`.
541			if strings.HasPrefix(cmd, "-") {
542				continue
543			}
544			if _, ok := cliUses[cmd]; !ok {
545				report.Add(levelWarning, rel(readmePath), i+1, "documented CLI command not found in cobra commands", fmt.Sprintf("command: dumber %s", cmd))
546			}
547		}
548	}
549}
550
551func verifyReadmeBuildClaims(report *Report, readmePath string, lines []string) {
552	for i, line := range lines {
553		low := strings.ToLower(line)
554		if strings.Contains(low, "webkit_cgo") {
555			report.Add(levelWarning, rel(readmePath), i+1, "README references old CGO build tag", "reference: webkit_cgo")
556		}
557		if strings.Contains(low, "cgo") && strings.Contains(low, "enabled") {
558			report.Add(levelWarning, rel(readmePath), i+1, "README references CGO build requirements", "reference: CGO enabled")
559		}
560		if strings.Contains(low, "gotk4") {
561			report.Add(levelWarning, rel(readmePath), i+1, "README references gotk4 (refactor moved to puregotk)", "reference: gotk4")
562		}
563	}
564}
565
566func writeReport(path string, report *Report) error {
567	if report == nil {
568		return errors.New("missing report")
569	}
570
571	var buf bytes.Buffer
572	w := bufio.NewWriter(&buf)
573
574	writeSeparator(w)
575	_, _ = fmt.Fprintln(w, "DUMBER DOCUMENTATION VERIFICATION REPORT")
576	writeSeparator(w)
577	_, _ = fmt.Fprintf(w, "Generated: %s\n\n", report.GeneratedAt.Format(time.RFC3339))
578
579	_, _ = fmt.Fprintf(w, "Summary: %d errors, %d warnings, %d info\n\n", report.Count(levelError), report.Count(levelWarning), report.Count(levelInfo))
580
581	issues := append([]Issue(nil), report.Issues...)
582	sort.SliceStable(issues, func(i, j int) bool {
583		if issues[i].Level != issues[j].Level {
584			return issues[i].Level < issues[j].Level
585		}
586		if issues[i].File != issues[j].File {
587			return issues[i].File < issues[j].File
588		}
589		return issues[i].Line < issues[j].Line
590	})
591
592	for _, lvl := range []issueLevel{levelError, levelWarning, levelInfo} {
593		writeSeparator(w)
594		_, _ = fmt.Fprintf(w, "%sS\n", lvl)
595		writeSeparator(w)
596		idx := 0
597		for _, it := range issues {
598			if it.Level != lvl {
599				continue
600			}
601			idx++
602			loc := it.File
603			if it.Line > 0 {
604				loc = fmt.Sprintf("%s:%d", it.File, it.Line)
605			}
606			_, _ = fmt.Fprintf(w, "[%d] %s\n", idx, loc)
607			_, _ = fmt.Fprintf(w, "    %s\n", it.Message)
608			if it.Detail != "" {
609				_, _ = fmt.Fprintf(w, "    %s\n", it.Detail)
610			}
611			_, _ = fmt.Fprintln(w)
612		}
613		if idx == 0 {
614			_, _ = fmt.Fprintln(w, "(none)")
615			_, _ = fmt.Fprintln(w)
616		}
617	}
618
619	if err := w.Flush(); err != nil {
620		return err
621	}
622
623	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
624		return err
625	}
626	return os.WriteFile(path, buf.Bytes(), 0o644)
627}
628
629func writeSeparator(w io.Writer) {
630	_, _ = fmt.Fprintln(w, strings.Repeat("=", 80))
631}
632
633func printSummary(report *Report, reportPath string) {
634	fmt.Printf("check-docs: wrote %s\n", rel(reportPath))
635	fmt.Printf("check-docs: %d errors, %d warnings, %d info\n", report.Count(levelError), report.Count(levelWarning), report.Count(levelInfo))
636	if report.Count(levelError) > 0 {
637		fmt.Printf("check-docs: FAIL\n")
638	} else {
639		fmt.Printf("check-docs: OK\n")
640	}
641}
642
643func rel(path string) string {
644	wd, err := os.Getwd()
645	if err != nil {
646		return path
647	}
648	rel, err := filepath.Rel(wd, path)
649	if err != nil {
650		return path
651	}
652	if strings.HasPrefix(rel, "..") {
653		return path
654	}
655	return rel
656}
657
658var _ = errors.Is // keep go vet happy for future.

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close