detached bb3c65f

docs: align README and CONFIG with pure Go refactor and add verification tool

commit
bb3c65f29d02a26a103d75fac60178d596be6f1d
author
brice · · 2025-12-21 21:46 UTC
committer
brice · · 2025-12-21 18:51 UTC
parent
6588389
signature
unsigned
5 files · +665 -66
M .gitignore +1 -0
@@ -70,6 +70,7 @@ node_modules/
7070
7171
7272 # Development/tooling files
73+dev/docs-report.txt
7374 CLAUDE.md
7475 CLAUDE.local.md
7576 .claude/
@@ -70,6 +70,7 @@ node_modules/
70 70
71 71
72 # Development/tooling files72 # Development/tooling files
73+dev/docs-report.txt
73 CLAUDE.md74 CLAUDE.md
74 CLAUDE.local.md75 CLAUDE.local.md
75 .claude/76 .claude/
M Makefile +4 -1
@@ -1,6 +1,6 @@
11 # Makefile for dumber (Clean Architecture - puregotk)
22
3-.PHONY: build build-frontend test lint clean install-tools dev generate help init
3+.PHONY: build build-frontend test lint clean install-tools dev generate help init check-docs
44
55 # Load local overrides from .env.local if present (Makefile syntax)
66 ifneq (,$(wildcard .env.local))
@@ -71,6 +71,9 @@ test: ## Run tests
7171 @echo "Running tests..."
7272 go test -v ./...
7373
74+check-docs: ## Verify README/docs match code
75+ @go run ./dev/check-docs.go --fail-on-error
76+
7477 test-race: ## Run tests with race detection
7578 @echo "Running tests with race detection..."
7679 go test -race -v ./...
@@ -1,6 +1,6 @@
1 # Makefile for dumber (Clean Architecture - puregotk)1 # Makefile for dumber (Clean Architecture - puregotk)
2 2
3-.PHONY: build build-frontend test lint clean install-tools dev generate help init3+.PHONY: build build-frontend test lint clean install-tools dev generate help init check-docs
4 4
5 # Load local overrides from .env.local if present (Makefile syntax)5 # Load local overrides from .env.local if present (Makefile syntax)
6 ifneq (,$(wildcard .env.local))6 ifneq (,$(wildcard .env.local))
@@ -71,6 +71,9 @@ test: ## Run tests
71 @echo "Running tests..."71 @echo "Running tests..."
72 go test -v ./...72 go test -v ./...
73 73
74+check-docs: ## Verify README/docs match code
75+ @go run ./dev/check-docs.go --fail-on-error
76+
74 test-race: ## Run tests with race detection77 test-race: ## Run tests with race detection
75 @echo "Running tests with race detection..."78 @echo "Running tests with race detection..."
76 go test -race -v ./...79 go test -race -v ./...
M README.md +29 -36
@@ -14,10 +14,10 @@ A dumb browser that works like your favorite terminal multiplexer.
1414 ## Features
1515 - **Wayland native**: No X11/XWayland dependency, works with Sway, Hyprland, River, Niri, etc.
1616 - **Tabs and workspaces**: Each tab holds a workspace with split or stacked panes.
17-- **Keyboard-driven**: Complete keyboard workflow inspired by Zellij.
17+- **Keyboard-driven**: Keyboard workflow inspired by Zellij.
1818 - **Smart popup handling**: OAuth flows, window.open(), and popup deduplication for tiling WMs.
1919 - **GPU rendering**: Hardware video acceleration with automatic VA-API/VDPAU detection.
20-- **Built-in ad blocking**: UBlock-based network filtering (cosmetic filtering in progress).
20+- **Built-in ad blocking**: UBlock-based network + cosmetic filtering.
2121 - **Launcher integration**: dmenu-style with rofi/fuzzel support, shows favicons and history.
2222 - **Search shortcuts**: Quick search via bangs (e.g., `!g golang` for Google, `!gi cats` for Google Images).
2323 - **Customizable themes**: Light and dark palettes with semantic color tokens.
@@ -25,6 +25,10 @@ A dumb browser that works like your favorite terminal multiplexer.
2525 - **Live configuration**: Single config file with hot reload when possible.
2626
2727 ## Status
28+Dumber recently completed a full rewrite in pure Go using the `puregotk` and `puregotk-webkit` libraries.
29+
30+The project is now structured around clean architecture, which makes it much easier to iterate on features and improve testability.
31+
2832 Early development with regular releases. Core features work well for daily use but expect some rough edges.
2933
3034 ## Controls & Shortcuts
@@ -58,7 +62,6 @@ Early development with regular releases. Core features work well for daily use b
5862 | **X** (in tab mode) | Close Tab | Close current tab |
5963 | **L** / **Tab** (in tab mode) | Next Tab | Navigate to next tab |
6064 | **H** / **Shift+Tab** (in tab mode) | Previous Tab | Navigate to previous tab |
61-| **R** (in tab mode) | Rename Tab | Rename current tab |
6265 | **Escape** (in tab mode) | Exit Tab Mode | Return to normal navigation |
6366
6467 #### Pane Management
@@ -124,15 +127,15 @@ Prerequisites:
124127 - Verify your system with: `dumber doctor` (or configure `runtime.prefix` for `/opt` installs)
125128
126129 Build options:
127-- GUI (default, recommended):
128- - `make build` # builds frontend + Go with `-tags=webkit_cgo`
129- - Run: `./dist/dumber` # to see all available commands.
130-- CLI‑only (no GUI, CGO disabled):
131- - `make build-no-gui`
132- - Run: `./dist/dumber-no-gui version`
130+- Default (recommended):
131+ - `make build` # builds frontend + Go (pure Go; CGO off)
132+ - Run: `./dist/dumber` # run the app
133+- Quick (no frontend rebuild):
134+ - `make build-quick`
135+ - Run: `./dist/dumber`
133136
134137 Development:
135-- `make dev` runs `go run .` (non‑CGO; GUI stubs log to console)
138+- `make dev` runs `go run ./cmd/dumber`
136139 - `make test` runs tests; `make lint` runs golangci‑lint
137140
138141 ## Install
@@ -171,7 +174,7 @@ sudo pacman -S gst-plugin-va
171174
172175 **Debian/Ubuntu:**
173176
174-> ⚠️ **Note:** Ubuntu 24.04 LTS ships with glib 2.80, but this project requires glib 2.84+ due to gotk4 bindings. Use Arch Linux, Fedora 41+, or another distribution with recent glib packages.
177+> ⚠️ **Note:** Ubuntu 24.04 LTS ships with GLib 2.80, but this project requires GLib 2.84+ due to newer GTK/WebKitGTK runtime requirements. Use Arch Linux, Fedora 41+, or another distribution with recent GLib packages.
175178
176179 ```bash
177180 # Core WebKitGTK and GTK4
@@ -210,10 +213,10 @@ sudo apt install va-driver-all
210213 - Open a URL or search:
211214 - `dumber browse https://example.com`
212215 - `dumber browse example.com` # scheme auto‑added
213- - `dumber browse dumb://home` # built-in homepage with stats
216+ - `dumber browse dumb://home` # built-in home page (stats, shortcuts, etc.)
214217 - `dumber browse "!g golang"` # Google search via bang shortcut
215218 - Show version information:
216- - `dumber version` # display version, commit, and build date
219+ - `dumber about` # display version, commit, and build date
217220 - Launcher integration (dmenu‑style examples with favicon support):
218221 - rofi: `dumber dmenu | rofi -dmenu -show-icons -p "🔍 " | dumber dmenu --select`
219222 - fuzzel: `dumber dmenu | fuzzel --dmenu -p "🔍 " | dumber dmenu --select`
@@ -241,7 +244,9 @@ You can invoke dmenu mode in two ways:
241244
242245 Note: The root flag path only generates options; for processing a selection (`--select`), use the `dmenu` subcommand as the receiving command.
243246
244-In GUI mode the app serves an embedded homepage via `dumb://homepage`, and frontend assets under `dumb://app/...`.
247+In GUI mode the app serves an embedded home page via `dumb://home`, and frontend assets under `dumb://app/...`.
248+
249+There is also a WIP config UI at `dumb://config` for viewing/applying settings with live reload.
245250
246251 ## Tab Management
247252
@@ -252,7 +257,6 @@ Each tab holds its own workspace. Each workspace can contain multiple panes (spl
252257 2. **Create/Close Tabs**: Use keyboard shortcuts:
253258 - `N` or `C` - New tab
254259 - `X` - Close current tab
255- - `R` - Rename tab
256260 3. **Navigate**: Use `Ctrl+Tab` / `Ctrl+Shift+Tab` for quick tab switching, or `L`/`H` in tab mode
257261 4. **Exit**: Press `Escape` to exit tab mode or wait for timeout
258262
@@ -283,13 +287,11 @@ A default config is created on first run. Changes are applied automatically (hot
283287
284288
285289 ## Building With WebKitGTK 6 (GTK4)
286-- The GUI requires building with `-tags=webkit_cgo` and CGO enabled.
287-- Ensure WebKitGTK 6 and GTK4 dev headers are installed (see prerequisites).
288-- Make targets handle both frontend and Go build steps:
289- - `make build-gui` (CGO enabled)
290- - `make build` (CGO enabled by default in the Makefile’s main build)
291-
292-Without the native tag, a stub backend is used: you can still run CLI flows and see logs, but no native window is displayed.
290+- Dumber uses pure-Go bindings (no CGO).
291+- You still need the system GTK4/WebKitGTK runtime libraries installed to run the GUI.
292+- Build targets:
293+ - `make build` (builds frontend + binary)
294+ - `make build-quick` (binary only; skips frontend build)
293295
294296 ## Media (GStreamer) & Hardware Acceleration
295297
@@ -311,22 +313,13 @@ WebKitGTK uses GStreamer for media playback. Dumber includes automatic hardware
311313 - Hardware decoding: `gstreamer1.0-plugins-bad` includes VA stateless decoders
312314 - VA-API drivers: `va-driver-all` (covers most GPUs)
313315
316+## References
317+- puregotk: https://github.com/jwijenbergh/puregotk
318+- puregotk-webkit: https://github.com/bnema/puregotk-webkit
319+- ublock-webkit-filters (filter list releases): https://github.com/bnema/ublock-webkit-filters
320+
314321 ## Contribute
315322 I will gladly accept contributions, especially related to UI/UX enhancements. Feel free to open an issue and let's discuss it!
316323
317-## Security & Privacy
318-- History and zoom settings are stored locally in SQLite under XDG state.
319-- The `tmp/` directory is ignored by Git and kept out of history.
320-
321-## Roadmap
322-- ✅ WebKitGTK 6 (GTK4) migration (GPU/Vulkan path complete)
323-- ✅ GPU rendering (Vulkan via GTK4 renderer) with graceful CPU fallback
324-- ✅ Modal pane management with binary tree layout and keyboard-driven workflow
325-- ✅ Tab management system with modal controls
326-- 🚧 UBlock-based content filtering (early stage - network blocking works, cosmetic filtering needs work)
327-- Full Vim-style motion and keyboard navigation across pages and UI
328-- Performance work: faster startup, lower memory, snappier UI
329-- Other shiny things I'm not yet aware of
330-
331324 ## License
332325 This project is licensed under the MIT License. See `LICENSE` for details.
@@ -14,10 +14,10 @@ A dumb browser that works like your favorite terminal multiplexer.
14 ## Features14 ## Features
15 - **Wayland native**: No X11/XWayland dependency, works with Sway, Hyprland, River, Niri, etc.15 - **Wayland native**: No X11/XWayland dependency, works with Sway, Hyprland, River, Niri, etc.
16 - **Tabs and workspaces**: Each tab holds a workspace with split or stacked panes.16 - **Tabs and workspaces**: Each tab holds a workspace with split or stacked panes.
17-- **Keyboard-driven**: Complete keyboard workflow inspired by Zellij.17+- **Keyboard-driven**: Keyboard workflow inspired by Zellij.
18 - **Smart popup handling**: OAuth flows, window.open(), and popup deduplication for tiling WMs.18 - **Smart popup handling**: OAuth flows, window.open(), and popup deduplication for tiling WMs.
19 - **GPU rendering**: Hardware video acceleration with automatic VA-API/VDPAU detection.19 - **GPU rendering**: Hardware video acceleration with automatic VA-API/VDPAU detection.
20-- **Built-in ad blocking**: UBlock-based network filtering (cosmetic filtering in progress).20+- **Built-in ad blocking**: UBlock-based network + cosmetic filtering.
21 - **Launcher integration**: dmenu-style with rofi/fuzzel support, shows favicons and history.21 - **Launcher integration**: dmenu-style with rofi/fuzzel support, shows favicons and history.
22 - **Search shortcuts**: Quick search via bangs (e.g., `!g golang` for Google, `!gi cats` for Google Images).22 - **Search shortcuts**: Quick search via bangs (e.g., `!g golang` for Google, `!gi cats` for Google Images).
23 - **Customizable themes**: Light and dark palettes with semantic color tokens.23 - **Customizable themes**: Light and dark palettes with semantic color tokens.
@@ -25,6 +25,10 @@ A dumb browser that works like your favorite terminal multiplexer.
25 - **Live configuration**: Single config file with hot reload when possible.25 - **Live configuration**: Single config file with hot reload when possible.
26 26
27 ## Status27 ## Status
28+Dumber recently completed a full rewrite in pure Go using the `puregotk` and `puregotk-webkit` libraries.
29+
30+The project is now structured around clean architecture, which makes it much easier to iterate on features and improve testability.
31+
28 Early development with regular releases. Core features work well for daily use but expect some rough edges.32 Early development with regular releases. Core features work well for daily use but expect some rough edges.
29 33
30 ## Controls & Shortcuts34 ## Controls & Shortcuts
@@ -58,7 +62,6 @@ Early development with regular releases. Core features work well for daily use b
58 | **X** (in tab mode) | Close Tab | Close current tab |62 | **X** (in tab mode) | Close Tab | Close current tab |
59 | **L** / **Tab** (in tab mode) | Next Tab | Navigate to next tab |63 | **L** / **Tab** (in tab mode) | Next Tab | Navigate to next tab |
60 | **H** / **Shift+Tab** (in tab mode) | Previous Tab | Navigate to previous tab |64 | **H** / **Shift+Tab** (in tab mode) | Previous Tab | Navigate to previous tab |
61-| **R** (in tab mode) | Rename Tab | Rename current tab |
62 | **Escape** (in tab mode) | Exit Tab Mode | Return to normal navigation |65 | **Escape** (in tab mode) | Exit Tab Mode | Return to normal navigation |
63 66
64 #### Pane Management67 #### Pane Management
@@ -124,15 +127,15 @@ Prerequisites:
124 - Verify your system with: `dumber doctor` (or configure `runtime.prefix` for `/opt` installs)127 - Verify your system with: `dumber doctor` (or configure `runtime.prefix` for `/opt` installs)
125 128
126 Build options:129 Build options:
127-- GUI (default, recommended):130+- Default (recommended):
128- - `make build` # builds frontend + Go with `-tags=webkit_cgo`131+ - `make build` # builds frontend + Go (pure Go; CGO off)
129- - Run: `./dist/dumber` # to see all available commands.132+ - Run: `./dist/dumber` # run the app
130-- CLI‑only (no GUI, CGO disabled):133+- Quick (no frontend rebuild):
131- - `make build-no-gui`134+ - `make build-quick`
132- - Run: `./dist/dumber-no-gui version`135+ - Run: `./dist/dumber`
133 136
134 Development:137 Development:
135-- `make dev` runs `go run .` (non‑CGO; GUI stubs log to console)138+- `make dev` runs `go run ./cmd/dumber`
136 - `make test` runs tests; `make lint` runs golangci‑lint139 - `make test` runs tests; `make lint` runs golangci‑lint
137 140
138 ## Install141 ## Install
@@ -171,7 +174,7 @@ sudo pacman -S gst-plugin-va
171 174
172 **Debian/Ubuntu:**175 **Debian/Ubuntu:**
173 176
174-> ⚠️ **Note:** Ubuntu 24.04 LTS ships with glib 2.80, but this project requires glib 2.84+ due to gotk4 bindings. Use Arch Linux, Fedora 41+, or another distribution with recent glib packages.177+> ⚠️ **Note:** Ubuntu 24.04 LTS ships with GLib 2.80, but this project requires GLib 2.84+ due to newer GTK/WebKitGTK runtime requirements. Use Arch Linux, Fedora 41+, or another distribution with recent GLib packages.
175 178
176 ```bash179 ```bash
177 # Core WebKitGTK and GTK4180 # Core WebKitGTK and GTK4
@@ -210,10 +213,10 @@ sudo apt install va-driver-all
210 - Open a URL or search:213 - Open a URL or search:
211 - `dumber browse https://example.com`214 - `dumber browse https://example.com`
212 - `dumber browse example.com` # scheme auto‑added215 - `dumber browse example.com` # scheme auto‑added
213- - `dumber browse dumb://home` # built-in homepage with stats216+ - `dumber browse dumb://home` # built-in home page (stats, shortcuts, etc.)
214 - `dumber browse "!g golang"` # Google search via bang shortcut217 - `dumber browse "!g golang"` # Google search via bang shortcut
215 - Show version information:218 - Show version information:
216- - `dumber version` # display version, commit, and build date219+ - `dumber about` # display version, commit, and build date
217 - Launcher integration (dmenu‑style examples with favicon support):220 - Launcher integration (dmenu‑style examples with favicon support):
218 - rofi: `dumber dmenu | rofi -dmenu -show-icons -p "🔍 " | dumber dmenu --select`221 - rofi: `dumber dmenu | rofi -dmenu -show-icons -p "🔍 " | dumber dmenu --select`
219 - fuzzel: `dumber dmenu | fuzzel --dmenu -p "🔍 " | dumber dmenu --select`222 - fuzzel: `dumber dmenu | fuzzel --dmenu -p "🔍 " | dumber dmenu --select`
@@ -241,7 +244,9 @@ You can invoke dmenu mode in two ways:
241 244
242 Note: The root flag path only generates options; for processing a selection (`--select`), use the `dmenu` subcommand as the receiving command.245 Note: The root flag path only generates options; for processing a selection (`--select`), use the `dmenu` subcommand as the receiving command.
243 246
244-In GUI mode the app serves an embedded homepage via `dumb://homepage`, and frontend assets under `dumb://app/...`.247+In GUI mode the app serves an embedded home page via `dumb://home`, and frontend assets under `dumb://app/...`.
248+
249+There is also a WIP config UI at `dumb://config` for viewing/applying settings with live reload.
245 250
246 ## Tab Management251 ## Tab Management
247 252
@@ -252,7 +257,6 @@ Each tab holds its own workspace. Each workspace can contain multiple panes (spl
252 2. **Create/Close Tabs**: Use keyboard shortcuts:257 2. **Create/Close Tabs**: Use keyboard shortcuts:
253 - `N` or `C` - New tab258 - `N` or `C` - New tab
254 - `X` - Close current tab259 - `X` - Close current tab
255- - `R` - Rename tab
256 3. **Navigate**: Use `Ctrl+Tab` / `Ctrl+Shift+Tab` for quick tab switching, or `L`/`H` in tab mode260 3. **Navigate**: Use `Ctrl+Tab` / `Ctrl+Shift+Tab` for quick tab switching, or `L`/`H` in tab mode
257 4. **Exit**: Press `Escape` to exit tab mode or wait for timeout261 4. **Exit**: Press `Escape` to exit tab mode or wait for timeout
258 262
@@ -283,13 +287,11 @@ A default config is created on first run. Changes are applied automatically (hot
283 287
284 288
285 ## Building With WebKitGTK 6 (GTK4)289 ## Building With WebKitGTK 6 (GTK4)
286-- The GUI requires building with `-tags=webkit_cgo` and CGO enabled.290+- Dumber uses pure-Go bindings (no CGO).
287-- Ensure WebKitGTK 6 and GTK4 dev headers are installed (see prerequisites).291+- You still need the system GTK4/WebKitGTK runtime libraries installed to run the GUI.
288-- Make targets handle both frontend and Go build steps:292+- Build targets:
289- - `make build-gui` (CGO enabled)293+ - `make build` (builds frontend + binary)
290- - `make build` (CGO enabled by default in the Makefile’s main build)294+ - `make build-quick` (binary only; skips frontend build)
291-
292-Without the native tag, a stub backend is used: you can still run CLI flows and see logs, but no native window is displayed.
293 295
294 ## Media (GStreamer) & Hardware Acceleration296 ## Media (GStreamer) & Hardware Acceleration
295 297
@@ -311,22 +313,13 @@ WebKitGTK uses GStreamer for media playback. Dumber includes automatic hardware
311 - Hardware decoding: `gstreamer1.0-plugins-bad` includes VA stateless decoders313 - Hardware decoding: `gstreamer1.0-plugins-bad` includes VA stateless decoders
312 - VA-API drivers: `va-driver-all` (covers most GPUs)314 - VA-API drivers: `va-driver-all` (covers most GPUs)
313 315
316+## References
317+- puregotk: https://github.com/jwijenbergh/puregotk
318+- puregotk-webkit: https://github.com/bnema/puregotk-webkit
319+- ublock-webkit-filters (filter list releases): https://github.com/bnema/ublock-webkit-filters
320+
314 ## Contribute321 ## Contribute
315 I will gladly accept contributions, especially related to UI/UX enhancements. Feel free to open an issue and let's discuss it!322 I will gladly accept contributions, especially related to UI/UX enhancements. Feel free to open an issue and let's discuss it!
316 323
317-## Security & Privacy
318-- History and zoom settings are stored locally in SQLite under XDG state.
319-- The `tmp/` directory is ignored by Git and kept out of history.
320-
321-## Roadmap
322-- ✅ WebKitGTK 6 (GTK4) migration (GPU/Vulkan path complete)
323-- ✅ GPU rendering (Vulkan via GTK4 renderer) with graceful CPU fallback
324-- ✅ Modal pane management with binary tree layout and keyboard-driven workflow
325-- ✅ Tab management system with modal controls
326-- 🚧 UBlock-based content filtering (early stage - network blocking works, cosmetic filtering needs work)
327-- Full Vim-style motion and keyboard navigation across pages and UI
328-- Performance work: faster startup, lower memory, snappier UI
329-- Other shiny things I'm not yet aware of
330-
331 ## License324 ## License
332 This project is licensed under the MIT License. See `LICENSE` for details.325 This project is licensed under the MIT License. See `LICENSE` for details.
A dev/check-docs.go +627 -0
@@ -0,0 +1,627 @@
1+package main
2+
3+import (
4+ "bufio"
5+ "bytes"
6+ "errors"
7+ "flag"
8+ "fmt"
9+ "go/ast"
10+ "go/parser"
11+ "go/token"
12+ "io"
13+ "os"
14+ "path/filepath"
15+ "regexp"
16+ "sort"
17+ "strings"
18+ "time"
19+)
20+
21+type issueLevel string
22+
23+const (
24+ levelError issueLevel = "ERROR"
25+ levelWarning issueLevel = "WARNING"
26+ levelInfo issueLevel = "INFO"
27+)
28+
29+type Issue struct {
30+ Level issueLevel
31+ File string
32+ Line int
33+ Message string
34+ Detail string
35+}
36+
37+type Report struct {
38+ GeneratedAt time.Time
39+ Issues []Issue
40+}
41+
42+func (r *Report) Add(level issueLevel, file string, line int, message, detail string) {
43+ r.Issues = append(r.Issues, Issue{Level: level, File: file, Line: line, Message: message, Detail: detail})
44+}
45+
46+func (r *Report) Count(level issueLevel) int {
47+ count := 0
48+ for _, it := range r.Issues {
49+ if it.Level == level {
50+ count++
51+ }
52+ }
53+ return count
54+}
55+
56+func main() {
57+ failOnError := flag.Bool("fail-on-error", false, "exit non-zero if errors found")
58+ out := flag.String("out", "dev/docs-report.txt", "write report to file (relative to repo root unless absolute)")
59+ flag.Parse()
60+
61+ repoRoot, err := findRepoRoot()
62+ if err != nil {
63+ fmt.Fprintln(os.Stderr, err)
64+ os.Exit(2)
65+ }
66+
67+ report := &Report{GeneratedAt: time.Now()}
68+
69+ schemaKeys, err := extractConfigKeys(filepath.Join(repoRoot, "internal", "infrastructure", "config", "schema.go"))
70+ if err != nil {
71+ fmt.Fprintln(os.Stderr, "failed to parse config schema:", err)
72+ os.Exit(2)
73+ }
74+
75+ actionConstByName, err := extractActions(filepath.Join(repoRoot, "internal", "ui", "input", "shortcuts.go"))
76+ if err != nil {
77+ fmt.Fprintln(os.Stderr, "failed to parse shortcuts:", err)
78+ os.Exit(2)
79+ }
80+
81+ stubActionConstNames, err := extractStubbedActionConstNames(filepath.Join(repoRoot, "internal", "ui", "dispatcher", "keyboard.go"))
82+ if err != nil {
83+ fmt.Fprintln(os.Stderr, "failed to parse keyboard dispatcher:", err)
84+ os.Exit(2)
85+ }
86+ stubActionValues := make(map[string]struct{}, len(stubActionConstNames))
87+ for _, name := range stubActionConstNames {
88+ if val, ok := actionConstByName[name]; ok {
89+ stubActionValues[val] = struct{}{}
90+ }
91+ }
92+
93+ cliUses, err := extractCobraUseStrings(filepath.Join(repoRoot, "internal", "cli", "cmd"))
94+ if err != nil {
95+ fmt.Fprintln(os.Stderr, "failed to parse cli commands:", err)
96+ os.Exit(2)
97+ }
98+
99+ readmePath := filepath.Join(repoRoot, "README.md")
100+ readmeLines, err := readLines(readmePath)
101+ if err != nil {
102+ fmt.Fprintln(os.Stderr, "failed to read README:", err)
103+ os.Exit(2)
104+ }
105+ configDocPath := filepath.Join(repoRoot, "docs", "CONFIG.md")
106+ configDocLines, err := readLines(configDocPath)
107+ if err != nil {
108+ fmt.Fprintln(os.Stderr, "failed to read docs/CONFIG.md:", err)
109+ os.Exit(2)
110+ }
111+
112+ verifyConfigDoc(report, schemaKeys, configDocPath, configDocLines)
113+ verifyReadmeShortcuts(report, stubActionValues, readmePath, readmeLines)
114+ verifyReadmeCommands(report, cliUses, readmePath, readmeLines)
115+ verifyReadmeBuildClaims(report, readmePath, readmeLines)
116+
117+ reportPath := *out
118+ if !filepath.IsAbs(reportPath) {
119+ reportPath = filepath.Join(repoRoot, reportPath)
120+ }
121+ if err := writeReport(reportPath, report); err != nil {
122+ fmt.Fprintln(os.Stderr, "failed to write report:", err)
123+ os.Exit(2)
124+ }
125+
126+ printSummary(report, reportPath)
127+
128+ if *failOnError && report.Count(levelError) > 0 {
129+ os.Exit(1)
130+ }
131+}
132+
133+func findRepoRoot() (string, error) {
134+ wd, err := os.Getwd()
135+ if err != nil {
136+ return "", err
137+ }
138+ dir := wd
139+ for {
140+ gitDir := filepath.Join(dir, ".git")
141+ if st, err := os.Stat(gitDir); err == nil && st.IsDir() {
142+ return dir, nil
143+ }
144+ parent := filepath.Dir(dir)
145+ if parent == dir {
146+ return "", errors.New("could not find repo root (.git)")
147+ }
148+ dir = parent
149+ }
150+}
151+
152+func readLines(path string) ([]string, error) {
153+ f, err := os.Open(path)
154+ if err != nil {
155+ return nil, err
156+ }
157+ defer f.Close()
158+
159+ var lines []string
160+ s := bufio.NewScanner(f)
161+ for s.Scan() {
162+ lines = append(lines, s.Text())
163+ }
164+ if err := s.Err(); err != nil {
165+ return nil, err
166+ }
167+ return lines, nil
168+}
169+
170+type schemaField struct {
171+ Tag string
172+ TypeName string
173+}
174+
175+func extractConfigKeys(schemaPath string) (map[string]struct{}, error) {
176+ fset := token.NewFileSet()
177+ file, err := parser.ParseFile(fset, schemaPath, nil, parser.ParseComments)
178+ if err != nil {
179+ return nil, err
180+ }
181+
182+ typeFields := map[string][]schemaField{}
183+ for _, decl := range file.Decls {
184+ gen, ok := decl.(*ast.GenDecl)
185+ if !ok || gen.Tok != token.TYPE {
186+ continue
187+ }
188+ for _, spec := range gen.Specs {
189+ ts, ok := spec.(*ast.TypeSpec)
190+ if !ok {
191+ continue
192+ }
193+ st, ok := ts.Type.(*ast.StructType)
194+ if !ok {
195+ continue
196+ }
197+ var fields []schemaField
198+ for _, f := range st.Fields.List {
199+ if len(f.Names) == 0 {
200+ continue
201+ }
202+ tag := extractStructTagValue(f.Tag, "mapstructure")
203+ if tag == "" || tag == "-" {
204+ continue
205+ }
206+ fields = append(fields, schemaField{Tag: tag, TypeName: typeExprName(f.Type)})
207+ }
208+ typeFields[ts.Name.Name] = fields
209+ }
210+ }
211+
212+ keys := make(map[string]struct{})
213+ seen := map[string]bool{}
214+ var walkType func(typeName, prefix string)
215+ walkType = func(typeName, prefix string) {
216+ if seen[prefix+"::"+typeName] {
217+ return
218+ }
219+ seen[prefix+"::"+typeName] = true
220+
221+ fields, ok := typeFields[typeName]
222+ if !ok {
223+ return
224+ }
225+ for _, f := range fields {
226+ full := f.Tag
227+ if prefix != "" {
228+ full = prefix + "." + f.Tag
229+ }
230+ keys[full] = struct{}{}
231+ if _, ok := typeFields[f.TypeName]; ok {
232+ walkType(f.TypeName, full)
233+ }
234+ }
235+ }
236+
237+ walkType("Config", "")
238+ return keys, nil
239+}
240+
241+func extractStructTagValue(tagLit *ast.BasicLit, key string) string {
242+ if tagLit == nil {
243+ return ""
244+ }
245+ tag := strings.Trim(tagLit.Value, "`")
246+ parts := strings.Split(tag, " ")
247+ for _, part := range parts {
248+ kv := strings.SplitN(part, ":", 2)
249+ if len(kv) != 2 {
250+ continue
251+ }
252+ k := kv[0]
253+ v := strings.Trim(kv[1], "\"")
254+ if k != key {
255+ continue
256+ }
257+ if idx := strings.IndexByte(v, ','); idx >= 0 {
258+ return v[:idx]
259+ }
260+ return v
261+ }
262+ return ""
263+}
264+
265+func typeExprName(expr ast.Expr) string {
266+ switch t := expr.(type) {
267+ case *ast.Ident:
268+ return t.Name
269+ case *ast.SelectorExpr:
270+ // pkg.Type
271+ return t.Sel.Name
272+ case *ast.StarExpr:
273+ return typeExprName(t.X)
274+ default:
275+ return ""
276+ }
277+}
278+
279+func extractActions(shortcutsPath string) (map[string]string, error) {
280+ fset := token.NewFileSet()
281+ file, err := parser.ParseFile(fset, shortcutsPath, nil, 0)
282+ if err != nil {
283+ return nil, err
284+ }
285+
286+ out := map[string]string{}
287+ for _, decl := range file.Decls {
288+ gen, ok := decl.(*ast.GenDecl)
289+ if !ok || gen.Tok != token.CONST {
290+ continue
291+ }
292+ for _, spec := range gen.Specs {
293+ vs, ok := spec.(*ast.ValueSpec)
294+ if !ok {
295+ continue
296+ }
297+ for i, name := range vs.Names {
298+ if i >= len(vs.Values) {
299+ continue
300+ }
301+ bl, ok := vs.Values[i].(*ast.BasicLit)
302+ if !ok || bl.Kind != token.STRING {
303+ continue
304+ }
305+ if strings.HasPrefix(name.Name, "Action") {
306+ out[name.Name] = strings.Trim(bl.Value, "\"")
307+ }
308+ }
309+ }
310+ }
311+ return out, nil
312+}
313+
314+func extractStubbedActionConstNames(keyboardDispatcherPath string) ([]string, error) {
315+ fset := token.NewFileSet()
316+ file, err := parser.ParseFile(fset, keyboardDispatcherPath, nil, 0)
317+ if err != nil {
318+ return nil, err
319+ }
320+
321+ stubbedSet := map[string]struct{}{}
322+
323+ ast.Inspect(file, func(n ast.Node) bool {
324+ cl, ok := n.(*ast.CompositeLit)
325+ if !ok {
326+ return true
327+ }
328+ if _, ok := cl.Type.(*ast.MapType); !ok {
329+ return true
330+ }
331+ for _, elt := range cl.Elts {
332+ kv, ok := elt.(*ast.KeyValueExpr)
333+ if !ok {
334+ continue
335+ }
336+ keyName := selectorName(kv.Key)
337+ if keyName == "" {
338+ continue
339+ }
340+ fn, ok := kv.Value.(*ast.FuncLit)
341+ if !ok {
342+ continue
343+ }
344+ if funcCallsSelector(fn, "logNoop") {
345+ stubbedSet[keyName] = struct{}{}
346+ }
347+ }
348+ return true
349+ })
350+
351+ var stubbed []string
352+ for k := range stubbedSet {
353+ stubbed = append(stubbed, k)
354+ }
355+ sort.Strings(stubbed)
356+ return stubbed, nil
357+}
358+
359+func selectorName(expr ast.Expr) string {
360+ switch t := expr.(type) {
361+ case *ast.SelectorExpr:
362+ return t.Sel.Name
363+ case *ast.Ident:
364+ return t.Name
365+ default:
366+ return ""
367+ }
368+}
369+
370+func funcCallsSelector(fn *ast.FuncLit, selName string) bool {
371+ if fn == nil || fn.Body == nil {
372+ return false
373+ }
374+ found := false
375+ ast.Inspect(fn.Body, func(n ast.Node) bool {
376+ call, ok := n.(*ast.CallExpr)
377+ if !ok {
378+ return true
379+ }
380+ sel, ok := call.Fun.(*ast.SelectorExpr)
381+ if !ok {
382+ return true
383+ }
384+ if sel.Sel.Name == selName {
385+ found = true
386+ return false
387+ }
388+ return true
389+ })
390+ return found
391+}
392+
393+func extractCobraUseStrings(cmdDir string) (map[string]struct{}, error) {
394+ entries, err := os.ReadDir(cmdDir)
395+ if err != nil {
396+ return nil, err
397+ }
398+
399+ uses := map[string]struct{}{}
400+ useRe := regexp.MustCompile(`\bUse:\s*"([^"]+)"`)
401+
402+ for _, e := range entries {
403+ if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") {
404+ continue
405+ }
406+ b, err := os.ReadFile(filepath.Join(cmdDir, e.Name()))
407+ if err != nil {
408+ return nil, err
409+ }
410+ matches := useRe.FindAllSubmatch(b, -1)
411+ for _, m := range matches {
412+ use := string(m[1])
413+ fields := strings.Fields(use)
414+ if len(fields) == 0 {
415+ continue
416+ }
417+ uses[fields[0]] = struct{}{}
418+ }
419+ }
420+
421+ return uses, nil
422+}
423+
424+func verifyConfigDoc(report *Report, schemaKeys map[string]struct{}, configDocPath string, lines []string) {
425+ docKeys := extractDocKeys(lines, schemaKeys)
426+ for key, line := range docKeys {
427+ if _, ok := schemaKeys[key]; ok {
428+ continue
429+ }
430+ report.Add(levelWarning, rel(configDocPath), line, "config key documented but not present in schema", fmt.Sprintf("key: %s", key))
431+ }
432+}
433+
434+func extractDocKeys(lines []string, schemaKeys map[string]struct{}) map[string]int {
435+ keyRe := regexp.MustCompile("`([a-zA-Z0-9_.-]+)`")
436+ inCode := false
437+
438+ seen := map[string]int{}
439+ for i, line := range lines {
440+ trim := strings.TrimSpace(line)
441+ if strings.HasPrefix(trim, "```") {
442+ inCode = !inCode
443+ continue
444+ }
445+ if inCode {
446+ continue
447+ }
448+ matches := keyRe.FindAllStringSubmatch(line, -1)
449+ for _, m := range matches {
450+ k := m[1]
451+ if _, ok := schemaKeys[k]; ok {
452+ if _, already := seen[k]; !already {
453+ seen[k] = i + 1
454+ }
455+ continue
456+ }
457+
458+ // For unknown backticked strings, only accept dotted paths that look
459+ // like config keys (filters out enum values like `most_visited`).
460+ if !strings.Contains(k, ".") {
461+ continue
462+ }
463+ if k == "" || k[0] < 'a' || k[0] > 'z' {
464+ continue
465+ }
466+ if strings.ContainsAny(k, " /") {
467+ continue
468+ }
469+ if _, already := seen[k]; !already {
470+ seen[k] = i + 1
471+ }
472+ }
473+ }
474+ return seen
475+}
476+
477+func verifyReadmeShortcuts(report *Report, stubActionValues map[string]struct{}, readmePath string, lines []string) {
478+ stubToDocPatterns := map[string][]string{
479+ "rename_tab": {"Rename Tab"},
480+ "toggle_fullscreen": {"Toggle Fullscreen"},
481+ }
482+ for action, patterns := range stubToDocPatterns {
483+ if _, stubbed := stubActionValues[action]; !stubbed {
484+ continue
485+ }
486+ for i, line := range lines {
487+ for _, p := range patterns {
488+ if strings.Contains(line, p) {
489+ report.Add(levelError, rel(readmePath), i+1, "documented shortcut/feature appears stubbed in code", fmt.Sprintf("action: %s", action))
490+ goto nextAction
491+ }
492+ }
493+ }
494+ nextAction:
495+ }
496+}
497+
498+func verifyReadmeCommands(report *Report, cliUses map[string]struct{}, readmePath string, lines []string) {
499+ cmdRe := regexp.MustCompile("`dumber\\s+([^`]+)`")
500+
501+ for i, line := range lines {
502+ matches := cmdRe.FindAllStringSubmatch(line, -1)
503+ for _, m := range matches {
504+ cmdLine := strings.TrimSpace(m[1])
505+ fields := strings.Fields(cmdLine)
506+ if len(fields) == 0 {
507+ continue
508+ }
509+ cmd := fields[0]
510+ // Ignore root flags like `dumber --dmenu`.
511+ if strings.HasPrefix(cmd, "-") {
512+ continue
513+ }
514+ if _, ok := cliUses[cmd]; !ok {
515+ report.Add(levelWarning, rel(readmePath), i+1, "documented CLI command not found in cobra commands", fmt.Sprintf("command: dumber %s", cmd))
516+ }
517+ }
518+ }
519+}
520+
521+func verifyReadmeBuildClaims(report *Report, readmePath string, lines []string) {
522+ for i, line := range lines {
523+ low := strings.ToLower(line)
524+ if strings.Contains(low, "webkit_cgo") {
525+ report.Add(levelWarning, rel(readmePath), i+1, "README references old CGO build tag", "reference: webkit_cgo")
526+ }
527+ if strings.Contains(low, "cgo") && strings.Contains(low, "enabled") {
528+ report.Add(levelWarning, rel(readmePath), i+1, "README references CGO build requirements", "reference: CGO enabled")
529+ }
530+ if strings.Contains(low, "gotk4") {
531+ report.Add(levelWarning, rel(readmePath), i+1, "README references gotk4 (refactor moved to puregotk)", "reference: gotk4")
532+ }
533+ }
534+}
535+
536+func writeReport(path string, report *Report) error {
537+ if report == nil {
538+ return errors.New("missing report")
539+ }
540+
541+ var buf bytes.Buffer
542+ w := bufio.NewWriter(&buf)
543+
544+ writeSeparator(w)
545+ _, _ = fmt.Fprintln(w, "DUMBER DOCUMENTATION VERIFICATION REPORT")
546+ writeSeparator(w)
547+ _, _ = fmt.Fprintf(w, "Generated: %s\n\n", report.GeneratedAt.Format(time.RFC3339))
548+
549+ _, _ = fmt.Fprintf(w, "Summary: %d errors, %d warnings, %d info\n\n", report.Count(levelError), report.Count(levelWarning), report.Count(levelInfo))
550+
551+ issues := append([]Issue(nil), report.Issues...)
552+ sort.SliceStable(issues, func(i, j int) bool {
553+ if issues[i].Level != issues[j].Level {
554+ return issues[i].Level < issues[j].Level
555+ }
556+ if issues[i].File != issues[j].File {
557+ return issues[i].File < issues[j].File
558+ }
559+ return issues[i].Line < issues[j].Line
560+ })
561+
562+ for _, lvl := range []issueLevel{levelError, levelWarning, levelInfo} {
563+ writeSeparator(w)
564+ _, _ = fmt.Fprintf(w, "%sS\n", lvl)
565+ writeSeparator(w)
566+ idx := 0
567+ for _, it := range issues {
568+ if it.Level != lvl {
569+ continue
570+ }
571+ idx++
572+ loc := it.File
573+ if it.Line > 0 {
574+ loc = fmt.Sprintf("%s:%d", it.File, it.Line)
575+ }
576+ _, _ = fmt.Fprintf(w, "[%d] %s\n", idx, loc)
577+ _, _ = fmt.Fprintf(w, " %s\n", it.Message)
578+ if it.Detail != "" {
579+ _, _ = fmt.Fprintf(w, " %s\n", it.Detail)
580+ }
581+ _, _ = fmt.Fprintln(w)
582+ }
583+ if idx == 0 {
584+ _, _ = fmt.Fprintln(w, "(none)\n")
585+ }
586+ }
587+
588+ if err := w.Flush(); err != nil {
589+ return err
590+ }
591+
592+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
593+ return err
594+ }
595+ return os.WriteFile(path, buf.Bytes(), 0o644)
596+}
597+
598+func writeSeparator(w io.Writer) {
599+ _, _ = fmt.Fprintln(w, strings.Repeat("=", 80))
600+}
601+
602+func printSummary(report *Report, reportPath string) {
603+ fmt.Printf("check-docs: wrote %s\n", rel(reportPath))
604+ fmt.Printf("check-docs: %d errors, %d warnings, %d info\n", report.Count(levelError), report.Count(levelWarning), report.Count(levelInfo))
605+ if report.Count(levelError) > 0 {
606+ fmt.Printf("check-docs: FAIL\n")
607+ } else {
608+ fmt.Printf("check-docs: OK\n")
609+ }
610+}
611+
612+func rel(path string) string {
613+ wd, err := os.Getwd()
614+ if err != nil {
615+ return path
616+ }
617+ rel, err := filepath.Rel(wd, path)
618+ if err != nil {
619+ return path
620+ }
621+ if strings.HasPrefix(rel, "..") {
622+ return path
623+ }
624+ return rel
625+}
626+
627+var _ = errors.Is // keep go vet happy for future.
@@ -0,0 +1,627 @@
1+package main
2+
3+import (
4+ "bufio"
5+ "bytes"
6+ "errors"
7+ "flag"
8+ "fmt"
9+ "go/ast"
10+ "go/parser"
11+ "go/token"
12+ "io"
13+ "os"
14+ "path/filepath"
15+ "regexp"
16+ "sort"
17+ "strings"
18+ "time"
19+)
20+
21+type issueLevel string
22+
23+const (
24+ levelError issueLevel = "ERROR"
25+ levelWarning issueLevel = "WARNING"
26+ levelInfo issueLevel = "INFO"
27+)
28+
29+type Issue struct {
30+ Level issueLevel
31+ File string
32+ Line int
33+ Message string
34+ Detail string
35+}
36+
37+type Report struct {
38+ GeneratedAt time.Time
39+ Issues []Issue
40+}
41+
42+func (r *Report) Add(level issueLevel, file string, line int, message, detail string) {
43+ r.Issues = append(r.Issues, Issue{Level: level, File: file, Line: line, Message: message, Detail: detail})
44+}
45+
46+func (r *Report) Count(level issueLevel) int {
47+ count := 0
48+ for _, it := range r.Issues {
49+ if it.Level == level {
50+ count++
51+ }
52+ }
53+ return count
54+}
55+
56+func main() {
57+ failOnError := flag.Bool("fail-on-error", false, "exit non-zero if errors found")
58+ out := flag.String("out", "dev/docs-report.txt", "write report to file (relative to repo root unless absolute)")
59+ flag.Parse()
60+
61+ repoRoot, err := findRepoRoot()
62+ if err != nil {
63+ fmt.Fprintln(os.Stderr, err)
64+ os.Exit(2)
65+ }
66+
67+ report := &Report{GeneratedAt: time.Now()}
68+
69+ schemaKeys, err := extractConfigKeys(filepath.Join(repoRoot, "internal", "infrastructure", "config", "schema.go"))
70+ if err != nil {
71+ fmt.Fprintln(os.Stderr, "failed to parse config schema:", err)
72+ os.Exit(2)
73+ }
74+
75+ actionConstByName, err := extractActions(filepath.Join(repoRoot, "internal", "ui", "input", "shortcuts.go"))
76+ if err != nil {
77+ fmt.Fprintln(os.Stderr, "failed to parse shortcuts:", err)
78+ os.Exit(2)
79+ }
80+
81+ stubActionConstNames, err := extractStubbedActionConstNames(filepath.Join(repoRoot, "internal", "ui", "dispatcher", "keyboard.go"))
82+ if err != nil {
83+ fmt.Fprintln(os.Stderr, "failed to parse keyboard dispatcher:", err)
84+ os.Exit(2)
85+ }
86+ stubActionValues := make(map[string]struct{}, len(stubActionConstNames))
87+ for _, name := range stubActionConstNames {
88+ if val, ok := actionConstByName[name]; ok {
89+ stubActionValues[val] = struct{}{}
90+ }
91+ }
92+
93+ cliUses, err := extractCobraUseStrings(filepath.Join(repoRoot, "internal", "cli", "cmd"))
94+ if err != nil {
95+ fmt.Fprintln(os.Stderr, "failed to parse cli commands:", err)
96+ os.Exit(2)
97+ }
98+
99+ readmePath := filepath.Join(repoRoot, "README.md")
100+ readmeLines, err := readLines(readmePath)
101+ if err != nil {
102+ fmt.Fprintln(os.Stderr, "failed to read README:", err)
103+ os.Exit(2)
104+ }
105+ configDocPath := filepath.Join(repoRoot, "docs", "CONFIG.md")
106+ configDocLines, err := readLines(configDocPath)
107+ if err != nil {
108+ fmt.Fprintln(os.Stderr, "failed to read docs/CONFIG.md:", err)
109+ os.Exit(2)
110+ }
111+
112+ verifyConfigDoc(report, schemaKeys, configDocPath, configDocLines)
113+ verifyReadmeShortcuts(report, stubActionValues, readmePath, readmeLines)
114+ verifyReadmeCommands(report, cliUses, readmePath, readmeLines)
115+ verifyReadmeBuildClaims(report, readmePath, readmeLines)
116+
117+ reportPath := *out
118+ if !filepath.IsAbs(reportPath) {
119+ reportPath = filepath.Join(repoRoot, reportPath)
120+ }
121+ if err := writeReport(reportPath, report); err != nil {
122+ fmt.Fprintln(os.Stderr, "failed to write report:", err)
123+ os.Exit(2)
124+ }
125+
126+ printSummary(report, reportPath)
127+
128+ if *failOnError && report.Count(levelError) > 0 {
129+ os.Exit(1)
130+ }
131+}
132+
133+func findRepoRoot() (string, error) {
134+ wd, err := os.Getwd()
135+ if err != nil {
136+ return "", err
137+ }
138+ dir := wd
139+ for {
140+ gitDir := filepath.Join(dir, ".git")
141+ if st, err := os.Stat(gitDir); err == nil && st.IsDir() {
142+ return dir, nil
143+ }
144+ parent := filepath.Dir(dir)
145+ if parent == dir {
146+ return "", errors.New("could not find repo root (.git)")
147+ }
148+ dir = parent
149+ }
150+}
151+
152+func readLines(path string) ([]string, error) {
153+ f, err := os.Open(path)
154+ if err != nil {
155+ return nil, err
156+ }
157+ defer f.Close()
158+
159+ var lines []string
160+ s := bufio.NewScanner(f)
161+ for s.Scan() {
162+ lines = append(lines, s.Text())
163+ }
164+ if err := s.Err(); err != nil {
165+ return nil, err
166+ }
167+ return lines, nil
168+}
169+
170+type schemaField struct {
171+ Tag string
172+ TypeName string
173+}
174+
175+func extractConfigKeys(schemaPath string) (map[string]struct{}, error) {
176+ fset := token.NewFileSet()
177+ file, err := parser.ParseFile(fset, schemaPath, nil, parser.ParseComments)
178+ if err != nil {
179+ return nil, err
180+ }
181+
182+ typeFields := map[string][]schemaField{}
183+ for _, decl := range file.Decls {
184+ gen, ok := decl.(*ast.GenDecl)
185+ if !ok || gen.Tok != token.TYPE {
186+ continue
187+ }
188+ for _, spec := range gen.Specs {
189+ ts, ok := spec.(*ast.TypeSpec)
190+ if !ok {
191+ continue
192+ }
193+ st, ok := ts.Type.(*ast.StructType)
194+ if !ok {
195+ continue
196+ }
197+ var fields []schemaField
198+ for _, f := range st.Fields.List {
199+ if len(f.Names) == 0 {
200+ continue
201+ }
202+ tag := extractStructTagValue(f.Tag, "mapstructure")
203+ if tag == "" || tag == "-" {
204+ continue
205+ }
206+ fields = append(fields, schemaField{Tag: tag, TypeName: typeExprName(f.Type)})
207+ }
208+ typeFields[ts.Name.Name] = fields
209+ }
210+ }
211+
212+ keys := make(map[string]struct{})
213+ seen := map[string]bool{}
214+ var walkType func(typeName, prefix string)
215+ walkType = func(typeName, prefix string) {
216+ if seen[prefix+"::"+typeName] {
217+ return
218+ }
219+ seen[prefix+"::"+typeName] = true
220+
221+ fields, ok := typeFields[typeName]
222+ if !ok {
223+ return
224+ }
225+ for _, f := range fields {
226+ full := f.Tag
227+ if prefix != "" {
228+ full = prefix + "." + f.Tag
229+ }
230+ keys[full] = struct{}{}
231+ if _, ok := typeFields[f.TypeName]; ok {
232+ walkType(f.TypeName, full)
233+ }
234+ }
235+ }
236+
237+ walkType("Config", "")
238+ return keys, nil
239+}
240+
241+func extractStructTagValue(tagLit *ast.BasicLit, key string) string {
242+ if tagLit == nil {
243+ return ""
244+ }
245+ tag := strings.Trim(tagLit.Value, "`")
246+ parts := strings.Split(tag, " ")
247+ for _, part := range parts {
248+ kv := strings.SplitN(part, ":", 2)
249+ if len(kv) != 2 {
250+ continue
251+ }
252+ k := kv[0]
253+ v := strings.Trim(kv[1], "\"")
254+ if k != key {
255+ continue
256+ }
257+ if idx := strings.IndexByte(v, ','); idx >= 0 {
258+ return v[:idx]
259+ }
260+ return v
261+ }
262+ return ""
263+}
264+
265+func typeExprName(expr ast.Expr) string {
266+ switch t := expr.(type) {
267+ case *ast.Ident:
268+ return t.Name
269+ case *ast.SelectorExpr:
270+ // pkg.Type
271+ return t.Sel.Name
272+ case *ast.StarExpr:
273+ return typeExprName(t.X)
274+ default:
275+ return ""
276+ }
277+}
278+
279+func extractActions(shortcutsPath string) (map[string]string, error) {
280+ fset := token.NewFileSet()
281+ file, err := parser.ParseFile(fset, shortcutsPath, nil, 0)
282+ if err != nil {
283+ return nil, err
284+ }
285+
286+ out := map[string]string{}
287+ for _, decl := range file.Decls {
288+ gen, ok := decl.(*ast.GenDecl)
289+ if !ok || gen.Tok != token.CONST {
290+ continue
291+ }
292+ for _, spec := range gen.Specs {
293+ vs, ok := spec.(*ast.ValueSpec)
294+ if !ok {
295+ continue
296+ }
297+ for i, name := range vs.Names {
298+ if i >= len(vs.Values) {
299+ continue
300+ }
301+ bl, ok := vs.Values[i].(*ast.BasicLit)
302+ if !ok || bl.Kind != token.STRING {
303+ continue
304+ }
305+ if strings.HasPrefix(name.Name, "Action") {
306+ out[name.Name] = strings.Trim(bl.Value, "\"")
307+ }
308+ }
309+ }
310+ }
311+ return out, nil
312+}
313+
314+func extractStubbedActionConstNames(keyboardDispatcherPath string) ([]string, error) {
315+ fset := token.NewFileSet()
316+ file, err := parser.ParseFile(fset, keyboardDispatcherPath, nil, 0)
317+ if err != nil {
318+ return nil, err
319+ }
320+
321+ stubbedSet := map[string]struct{}{}
322+
323+ ast.Inspect(file, func(n ast.Node) bool {
324+ cl, ok := n.(*ast.CompositeLit)
325+ if !ok {
326+ return true
327+ }
328+ if _, ok := cl.Type.(*ast.MapType); !ok {
329+ return true
330+ }
331+ for _, elt := range cl.Elts {
332+ kv, ok := elt.(*ast.KeyValueExpr)
333+ if !ok {
334+ continue
335+ }
336+ keyName := selectorName(kv.Key)
337+ if keyName == "" {
338+ continue
339+ }
340+ fn, ok := kv.Value.(*ast.FuncLit)
341+ if !ok {
342+ continue
343+ }
344+ if funcCallsSelector(fn, "logNoop") {
345+ stubbedSet[keyName] = struct{}{}
346+ }
347+ }
348+ return true
349+ })
350+
351+ var stubbed []string
352+ for k := range stubbedSet {
353+ stubbed = append(stubbed, k)
354+ }
355+ sort.Strings(stubbed)
356+ return stubbed, nil
357+}
358+
359+func selectorName(expr ast.Expr) string {
360+ switch t := expr.(type) {
361+ case *ast.SelectorExpr:
362+ return t.Sel.Name
363+ case *ast.Ident:
364+ return t.Name
365+ default:
366+ return ""
367+ }
368+}
369+
370+func funcCallsSelector(fn *ast.FuncLit, selName string) bool {
371+ if fn == nil || fn.Body == nil {
372+ return false
373+ }
374+ found := false
375+ ast.Inspect(fn.Body, func(n ast.Node) bool {
376+ call, ok := n.(*ast.CallExpr)
377+ if !ok {
378+ return true
379+ }
380+ sel, ok := call.Fun.(*ast.SelectorExpr)
381+ if !ok {
382+ return true
383+ }
384+ if sel.Sel.Name == selName {
385+ found = true
386+ return false
387+ }
388+ return true
389+ })
390+ return found
391+}
392+
393+func extractCobraUseStrings(cmdDir string) (map[string]struct{}, error) {
394+ entries, err := os.ReadDir(cmdDir)
395+ if err != nil {
396+ return nil, err
397+ }
398+
399+ uses := map[string]struct{}{}
400+ useRe := regexp.MustCompile(`\bUse:\s*"([^"]+)"`)
401+
402+ for _, e := range entries {
403+ if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") {
404+ continue
405+ }
406+ b, err := os.ReadFile(filepath.Join(cmdDir, e.Name()))
407+ if err != nil {
408+ return nil, err
409+ }
410+ matches := useRe.FindAllSubmatch(b, -1)
411+ for _, m := range matches {
412+ use := string(m[1])
413+ fields := strings.Fields(use)
414+ if len(fields) == 0 {
415+ continue
416+ }
417+ uses[fields[0]] = struct{}{}
418+ }
419+ }
420+
421+ return uses, nil
422+}
423+
424+func verifyConfigDoc(report *Report, schemaKeys map[string]struct{}, configDocPath string, lines []string) {
425+ docKeys := extractDocKeys(lines, schemaKeys)
426+ for key, line := range docKeys {
427+ if _, ok := schemaKeys[key]; ok {
428+ continue
429+ }
430+ report.Add(levelWarning, rel(configDocPath), line, "config key documented but not present in schema", fmt.Sprintf("key: %s", key))
431+ }
432+}
433+
434+func extractDocKeys(lines []string, schemaKeys map[string]struct{}) map[string]int {
435+ keyRe := regexp.MustCompile("`([a-zA-Z0-9_.-]+)`")
436+ inCode := false
437+
438+ seen := map[string]int{}
439+ for i, line := range lines {
440+ trim := strings.TrimSpace(line)
441+ if strings.HasPrefix(trim, "```") {
442+ inCode = !inCode
443+ continue
444+ }
445+ if inCode {
446+ continue
447+ }
448+ matches := keyRe.FindAllStringSubmatch(line, -1)
449+ for _, m := range matches {
450+ k := m[1]
451+ if _, ok := schemaKeys[k]; ok {
452+ if _, already := seen[k]; !already {
453+ seen[k] = i + 1
454+ }
455+ continue
456+ }
457+
458+ // For unknown backticked strings, only accept dotted paths that look
459+ // like config keys (filters out enum values like `most_visited`).
460+ if !strings.Contains(k, ".") {
461+ continue
462+ }
463+ if k == "" || k[0] < 'a' || k[0] > 'z' {
464+ continue
465+ }
466+ if strings.ContainsAny(k, " /") {
467+ continue
468+ }
469+ if _, already := seen[k]; !already {
470+ seen[k] = i + 1
471+ }
472+ }
473+ }
474+ return seen
475+}
476+
477+func verifyReadmeShortcuts(report *Report, stubActionValues map[string]struct{}, readmePath string, lines []string) {
478+ stubToDocPatterns := map[string][]string{
479+ "rename_tab": {"Rename Tab"},
480+ "toggle_fullscreen": {"Toggle Fullscreen"},
481+ }
482+ for action, patterns := range stubToDocPatterns {
483+ if _, stubbed := stubActionValues[action]; !stubbed {
484+ continue
485+ }
486+ for i, line := range lines {
487+ for _, p := range patterns {
488+ if strings.Contains(line, p) {
489+ report.Add(levelError, rel(readmePath), i+1, "documented shortcut/feature appears stubbed in code", fmt.Sprintf("action: %s", action))
490+ goto nextAction
491+ }
492+ }
493+ }
494+ nextAction:
495+ }
496+}
497+
498+func verifyReadmeCommands(report *Report, cliUses map[string]struct{}, readmePath string, lines []string) {
499+ cmdRe := regexp.MustCompile("`dumber\\s+([^`]+)`")
500+
501+ for i, line := range lines {
502+ matches := cmdRe.FindAllStringSubmatch(line, -1)
503+ for _, m := range matches {
504+ cmdLine := strings.TrimSpace(m[1])
505+ fields := strings.Fields(cmdLine)
506+ if len(fields) == 0 {
507+ continue
508+ }
509+ cmd := fields[0]
510+ // Ignore root flags like `dumber --dmenu`.
511+ if strings.HasPrefix(cmd, "-") {
512+ continue
513+ }
514+ if _, ok := cliUses[cmd]; !ok {
515+ report.Add(levelWarning, rel(readmePath), i+1, "documented CLI command not found in cobra commands", fmt.Sprintf("command: dumber %s", cmd))
516+ }
517+ }
518+ }
519+}
520+
521+func verifyReadmeBuildClaims(report *Report, readmePath string, lines []string) {
522+ for i, line := range lines {
523+ low := strings.ToLower(line)
524+ if strings.Contains(low, "webkit_cgo") {
525+ report.Add(levelWarning, rel(readmePath), i+1, "README references old CGO build tag", "reference: webkit_cgo")
526+ }
527+ if strings.Contains(low, "cgo") && strings.Contains(low, "enabled") {
528+ report.Add(levelWarning, rel(readmePath), i+1, "README references CGO build requirements", "reference: CGO enabled")
529+ }
530+ if strings.Contains(low, "gotk4") {
531+ report.Add(levelWarning, rel(readmePath), i+1, "README references gotk4 (refactor moved to puregotk)", "reference: gotk4")
532+ }
533+ }
534+}
535+
536+func writeReport(path string, report *Report) error {
537+ if report == nil {
538+ return errors.New("missing report")
539+ }
540+
541+ var buf bytes.Buffer
542+ w := bufio.NewWriter(&buf)
543+
544+ writeSeparator(w)
545+ _, _ = fmt.Fprintln(w, "DUMBER DOCUMENTATION VERIFICATION REPORT")
546+ writeSeparator(w)
547+ _, _ = fmt.Fprintf(w, "Generated: %s\n\n", report.GeneratedAt.Format(time.RFC3339))
548+
549+ _, _ = fmt.Fprintf(w, "Summary: %d errors, %d warnings, %d info\n\n", report.Count(levelError), report.Count(levelWarning), report.Count(levelInfo))
550+
551+ issues := append([]Issue(nil), report.Issues...)
552+ sort.SliceStable(issues, func(i, j int) bool {
553+ if issues[i].Level != issues[j].Level {
554+ return issues[i].Level < issues[j].Level
555+ }
556+ if issues[i].File != issues[j].File {
557+ return issues[i].File < issues[j].File
558+ }
559+ return issues[i].Line < issues[j].Line
560+ })
561+
562+ for _, lvl := range []issueLevel{levelError, levelWarning, levelInfo} {
563+ writeSeparator(w)
564+ _, _ = fmt.Fprintf(w, "%sS\n", lvl)
565+ writeSeparator(w)
566+ idx := 0
567+ for _, it := range issues {
568+ if it.Level != lvl {
569+ continue
570+ }
571+ idx++
572+ loc := it.File
573+ if it.Line > 0 {
574+ loc = fmt.Sprintf("%s:%d", it.File, it.Line)
575+ }
576+ _, _ = fmt.Fprintf(w, "[%d] %s\n", idx, loc)
577+ _, _ = fmt.Fprintf(w, " %s\n", it.Message)
578+ if it.Detail != "" {
579+ _, _ = fmt.Fprintf(w, " %s\n", it.Detail)
580+ }
581+ _, _ = fmt.Fprintln(w)
582+ }
583+ if idx == 0 {
584+ _, _ = fmt.Fprintln(w, "(none)\n")
585+ }
586+ }
587+
588+ if err := w.Flush(); err != nil {
589+ return err
590+ }
591+
592+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
593+ return err
594+ }
595+ return os.WriteFile(path, buf.Bytes(), 0o644)
596+}
597+
598+func writeSeparator(w io.Writer) {
599+ _, _ = fmt.Fprintln(w, strings.Repeat("=", 80))
600+}
601+
602+func printSummary(report *Report, reportPath string) {
603+ fmt.Printf("check-docs: wrote %s\n", rel(reportPath))
604+ fmt.Printf("check-docs: %d errors, %d warnings, %d info\n", report.Count(levelError), report.Count(levelWarning), report.Count(levelInfo))
605+ if report.Count(levelError) > 0 {
606+ fmt.Printf("check-docs: FAIL\n")
607+ } else {
608+ fmt.Printf("check-docs: OK\n")
609+ }
610+}
611+
612+func rel(path string) string {
613+ wd, err := os.Getwd()
614+ if err != nil {
615+ return path
616+ }
617+ rel, err := filepath.Rel(wd, path)
618+ if err != nil {
619+ return path
620+ }
621+ if strings.HasPrefix(rel, "..") {
622+ return path
623+ }
624+ return rel
625+}
626+
627+var _ = errors.Is // keep go vet happy for future.
M docs/CONFIG.md +4 -29
@@ -266,7 +266,6 @@ cancel = ["escape"]
266266 | `workspace.styling.tab_mode_border_width` | int | `4` | Tab mode border width (px) - Ctrl+P T overlay |
267267 | `workspace.styling.tab_mode_border_color` | string | `"#FFA500"` | Tab mode border color (orange) |
268268 | `workspace.styling.transition_duration` | int | `120` | Border transition duration (ms) |
269-| `workspace.styling.ui_scale` | float | `1.0` | UI scale multiplier (1.0 = 100%, 1.2 = 120%) |
270269
271270 ## Media
272271
@@ -318,35 +317,11 @@ prefix = "/opt/webkitgtk"
318317 | Key | Type | Default | Description |
319318 |-----|------|---------|-------------|
320319 | `content_filtering.enabled` | bool | `true` | Enable ad blocking |
321-| `content_filtering.filter_lists` | array | See below | Filter list URLs to use |
320+| `content_filtering.auto_update` | bool | `true` | Automatically update filters |
322321
323-> **Note:** Domain whitelist is managed via the database (`content_whitelist` table), not the config file.
324-
325-**Default filter lists:**
326-```toml
327-[content_filtering]
328-filter_lists = [
329- # Core blocking
330- "https://easylist.to/easylist/easylist.txt", # Ads
331- "https://easylist.to/easylist/easyprivacy.txt", # Tracking
332- # uBlock extras
333- "https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/filters.txt", # uBO optimizations
334- "https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/annoyances.txt", # Cookie banners, popups
335- "https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/quick-fixes.txt" # Site fixes
336-]
337-```
338-
339-**Custom filter lists example:**
340-```toml
341-[content_filtering]
342-filter_lists = [
343- # Minimal setup
344- "https://easylist.to/easylist/easylist.txt",
345- "https://easylist.to/easylist/easyprivacy.txt",
346- # Regional list
347- "https://easylist-downloads.adblockplus.org/liste_fr.txt"
348-]
349-```
322+Notes:
323+- Filter data is downloaded from `bnema/ublock-webkit-filters` GitHub releases.
324+- Domain whitelist is managed via the database (`content_whitelist` table), not the config file.
350325
351326 ## Environment Variables
352327
@@ -266,7 +266,6 @@ cancel = ["escape"]
266 | `workspace.styling.tab_mode_border_width` | int | `4` | Tab mode border width (px) - Ctrl+P T overlay |266 | `workspace.styling.tab_mode_border_width` | int | `4` | Tab mode border width (px) - Ctrl+P T overlay |
267 | `workspace.styling.tab_mode_border_color` | string | `"#FFA500"` | Tab mode border color (orange) |267 | `workspace.styling.tab_mode_border_color` | string | `"#FFA500"` | Tab mode border color (orange) |
268 | `workspace.styling.transition_duration` | int | `120` | Border transition duration (ms) |268 | `workspace.styling.transition_duration` | int | `120` | Border transition duration (ms) |
269-| `workspace.styling.ui_scale` | float | `1.0` | UI scale multiplier (1.0 = 100%, 1.2 = 120%) |
270 269
271 ## Media270 ## Media
272 271
@@ -318,35 +317,11 @@ prefix = "/opt/webkitgtk"
318 | Key | Type | Default | Description |317 | Key | Type | Default | Description |
319 |-----|------|---------|-------------|318 |-----|------|---------|-------------|
320 | `content_filtering.enabled` | bool | `true` | Enable ad blocking |319 | `content_filtering.enabled` | bool | `true` | Enable ad blocking |
321-| `content_filtering.filter_lists` | array | See below | Filter list URLs to use |320+| `content_filtering.auto_update` | bool | `true` | Automatically update filters |
322 321
323-> **Note:** Domain whitelist is managed via the database (`content_whitelist` table), not the config file.322+Notes:
324-323+- Filter data is downloaded from `bnema/ublock-webkit-filters` GitHub releases.
325-**Default filter lists:**324+- Domain whitelist is managed via the database (`content_whitelist` table), not the config file.
326-```toml
327-[content_filtering]
328-filter_lists = [
329- # Core blocking
330- "https://easylist.to/easylist/easylist.txt", # Ads
331- "https://easylist.to/easylist/easyprivacy.txt", # Tracking
332- # uBlock extras
333- "https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/filters.txt", # uBO optimizations
334- "https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/annoyances.txt", # Cookie banners, popups
335- "https://raw.githubusercontent.com/uBlockOrigin/uAssets/master/filters/quick-fixes.txt" # Site fixes
336-]
337-```
338-
339-**Custom filter lists example:**
340-```toml
341-[content_filtering]
342-filter_lists = [
343- # Minimal setup
344- "https://easylist.to/easylist/easylist.txt",
345- "https://easylist.to/easylist/easyprivacy.txt",
346- # Regional list
347- "https://easylist-downloads.adblockplus.org/liste_fr.txt"
348-]
349-```
350 325
351 ## Environment Variables326 ## Environment Variables
352 327

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