detached 752a1ae

chore: remove dev/ directory

commit
752a1ae1a699f3eeb65a16f64abe1b3ae92a5ac5
author
bnema · · 2026-01-06 18:16 UTC
committer
bnema · · 2026-01-06 19:04 UTC
parent
507145e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +0 -673
D dev/check-docs.go +0 -658
@@ -1,658 +0,0 @@
1-//go:build devtools
2-// +build devtools
3-
4-package main
5-
6-import (
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-
24-type issueLevel string
25-
26-const (
27- levelError issueLevel = "ERROR"
28- levelWarning issueLevel = "WARNING"
29- levelInfo issueLevel = "INFO"
30-)
31-
32-type Issue struct {
33- Level issueLevel
34- File string
35- Line int
36- Message string
37- Detail string
38-}
39-
40-type Report struct {
41- GeneratedAt time.Time
42- Issues []Issue
43-}
44-
45-func (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-
49-func (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-
59-func 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-
136-func 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-
155-func 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-
173-type schemaField struct {
174- Tag string
175- TypeName string
176-}
177-
178-func 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-
244-func 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-
268-func 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-
282-func 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-
317-func 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-
362-func 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-
373-func 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-
396-func 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-
427-func 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-
437-func 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-
507-func 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-
528-func 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-
551-func 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-
566-func 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-
629-func writeSeparator(w io.Writer) {
630- _, _ = fmt.Fprintln(w, strings.Repeat("=", 80))
631-}
632-
633-func 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-
643-func 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-
658-var _ = errors.Is // keep go vet happy for future.
@@ -1,658 +0,0 @@
1-//go:build devtools
2-// +build devtools
3-
4-package main
5-
6-import (
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-
24-type issueLevel string
25-
26-const (
27- levelError issueLevel = "ERROR"
28- levelWarning issueLevel = "WARNING"
29- levelInfo issueLevel = "INFO"
30-)
31-
32-type Issue struct {
33- Level issueLevel
34- File string
35- Line int
36- Message string
37- Detail string
38-}
39-
40-type Report struct {
41- GeneratedAt time.Time
42- Issues []Issue
43-}
44-
45-func (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-
49-func (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-
59-func 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-
136-func 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-
155-func 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-
173-type schemaField struct {
174- Tag string
175- TypeName string
176-}
177-
178-func 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-
244-func 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-
268-func 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-
282-func 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-
317-func 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-
362-func 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-
373-func 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-
396-func 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-
427-func 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-
437-func 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-
507-func 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-
528-func 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-
551-func 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-
566-func 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-
629-func writeSeparator(w io.Writer) {
630- _, _ = fmt.Fprintln(w, strings.Repeat("=", 80))
631-}
632-
633-func 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-
643-func 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-
658-var _ = errors.Is // keep go vet happy for future.
D dev/run-debug.fish +0 -15
@@ -1,15 +0,0 @@
1-#!/usr/bin/env fish
2-# dev/run-debug.fish - launch ./dist/dumber with verbose debug instrumentation
3-
4-# Enable core dumps so we get a crash backtrace if GLib aborts
5-ulimit -c unlimited
6-
7-# Go runtime crash dumps
8-set -gx GOTRACEBACK crash
9-
10-# Make GLib fatal on warnings/criticals and emit stack traces
11-set -gx G_DEBUG "fatal-criticals,fatal-warnings,stack-trace-on-fatal"
12-set -gx G_MESSAGES_DEBUG all
13-
14-# Execute the browser with any arguments forwarded
15-./dist/dumber $argv
@@ -1,15 +0,0 @@
1-#!/usr/bin/env fish
2-# dev/run-debug.fish - launch ./dist/dumber with verbose debug instrumentation
3-
4-# Enable core dumps so we get a crash backtrace if GLib aborts
5-ulimit -c unlimited
6-
7-# Go runtime crash dumps
8-set -gx GOTRACEBACK crash
9-
10-# Make GLib fatal on warnings/criticals and emit stack traces
11-set -gx G_DEBUG "fatal-criticals,fatal-warnings,stack-trace-on-fatal"
12-set -gx G_MESSAGES_DEBUG all
13-
14-# Execute the browser with any arguments forwarded
15-./dist/dumber $argv

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