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.