Codebase-quality audit, 2026-08-27. Ordered by severity (High → Low); within a
tier, by priority score (Impact + Risk) × (6 − Effort), each dimension 1–5
(effort inverted: cheaper fixes rank higher).
Overall health is well above average and the list below is graded on a
curve: go vet clean, zero TODO/FIXME markers in production code, all 30
test packages green, ~38k lines of tests against ~26.5k lines of code,
coverage 90–100% everywhere except thin OS-integration seams, an up-to-date
ARCHITECTURE.md, and a working refactoring cadence (finished_refactorings/).
The debt that remains is one upstream library bug (item 1, mitigated) and
the updater's dependency footprint (item 7, accepted debt to watch). The
second god object and the inverted dependency — items 4 and 2 — were
resolved on 2026-08-27 by the internal/dupes extraction, along with items
11, 13 and 14. Items 5 (menu recompute) and 6 (appState cache eviction)
were resolved on 2026-08-28. Item 3, the viewer god object, is retired
rather than re-scored: it had already moved to todos.md's TODO section
ahead of the plan that closed it, and now lives in todos.md Done →
Internal — four field-cluster extractions took viewer from 87 fields to
55, and what's left is largely widget references and already-grouped value
structs (state, settings, vector, display), a materially weaker
complaint than the original that no longer clears the bar for a scored
entry here.
- Impact 3 · Risk 5 · Effort 1 → priority 40
- Where:
go.mod—github.com/gen2brain/heic v0.7.1, imported byinternal/imaging/loader.go:29andinternal/imaging/exif.go:12. - The upstream wazero-based decoder leaks native memory on every decode
(gen2brain/heic issue #15; fix PR #16 filed by this project 2026-08-20).
v0.7.1 is still the latest release and the tree carries no
replacedirective or local mitigation, so a session browsing HEIC-heavy folders grows RSS without bound — multi-GB growth was observed during diagnosis. This is invisible to Go heap profiling (native memory), so it will not resurface in routine pprof checks. - Why it matters: crash-grade for end users with iPhone photo libraries — the app's core audience for this format.
- Fix: watch for the upstream release containing PR #16 and bump. If it
stalls, add a
replaceto the patched fork — one line, immediately shippable. Either way, add a note inAGENTS.mdso the pin isn't forgotten. - Mitigation (2026-08-26):
go.modreplace →frathe/heic@0ac0a39until upstream releases PR #16. Remove replace on bump.
- Impact 3 · Risk 3 · Effort 2 → priority 24 (highest score in the list — cheap and unlocks item 3's menu extraction)
- Where:
updateFileMenuState,updateActionsMenuState,updateWindowMenuState,refreshMainMenuhad to be remembered at every state-changing site — rotate.go, load.go, save.go,clearToDropzone, drop.go, and more. - The tell:
HighlightChangedsnapshotted four booleans, calledapplyActionsMenuState, then hand-diffed them to decide whether the native menu needed a refresh. That was an ad-hoc change-detection layer bolted onto push-based invalidation. - Fix: one
syncMenus()that recomputes all Checked/Disabled state from the model and internally diffs before touching the native bar, called from a small number of choke points (end of every user action). Deletes the per-site call discipline and the manual diffing. - Resolved (2026-08-28):
internal/ui/menus.Menus.Apply(State) (changed bool)recomputes the whole File/Window/Actions matrix as one pure function of a value snapshot;menu.go'smenuState()builds that snapshot in the one place, andsyncMenus()applies it, refreshing the native bar only whenApplyreportschanged. ReplacesupdateFileMenuState/updateActionsMenuState/updateWindowMenuState/applyActionsMenuState/applyWindowMenuStateand deletesHighlightChanged's four-boolean hand-diff outright. The push sites themselves were then audited and cut from 23 call sites to 16 choke points (7 removed, each justified by a covering feature observer). Seeinternal/ui/menus,ARCHITECTURE.md's row for it, andtodos.mdDone → Internal.
- Impact 1 · Risk 2 · Effort 2 → priority 12 (was 20; both halves landed 2026-08-28)
- Half done. The revision no longer lives outside
appState: thefileSetRevisioncounter is gone, andsetFiles/clearFiles/removeFile/reordereach end inpublish(), which republishes the file-set snapshot under a bumped generation as a matter of construction rather than caller convention.viewer.Generation()reads that snapshot. See the duplicate-snapshot work inplans/2026-08-28-dupes-followups.md. - Where: state.go still leaves one invariant to the
caller —
RemoveFile(viewer.go:820) evicts the image cache itself, so a future mutator that forgets that call leaks decodes of files that are gone. drop.go and sort.go still apply merge/sort ordering from outside. - Why it matters: cache eviction is now the only file-set invariant
nothing but convention enforces. The generation and the
files/unsortedFiles sync are both internal to
appState. - Fix: let the viewer subscribe to
appStatefor cache eviction, so removal evicts without the caller remembering to. Still shrinks item 3 as a side effect. - Resolved (2026-08-28): the eviction half landed too.
appStatecarries anonRemove func(fyne.URI)hook, fired fromremoveFileafterpublish()so a subscriber always sees the new generation first;build.gowires it to the viewer'simgCacheonce the viewer literal exists.RemoveFile(viewer.go:754) no longer evicts anything itself. drop.go/sort.go's merge/sort ordering from outsideappStateis unchanged and not part of this item. Seetodos.mdDone → Internal.
- Impact 2 · Risk 3 · Effort 5 → priority 5 (accepted debt — document, don't act)
- Where:
go.mod—sigstore-go,sigstore,go-tuf/v2pull in gRPC, OpenTelemetry, the go-openapi suite, certificate-transparency-go, and k8s klog: the majority of the ~120 indirect dependencies exist to verify release signatures for the in-app updater. - Why it matters: binary size, build time, CVE-scanner noise, and the
sigstore ecosystem's fast API churn all land on an app whose job is
showing pictures. The choice is deliberate and security-motivated, and
internal/updateisolates it well — so this is a watch item: revisit whether a TUF-only + bundle-verification subset can replace the full verifier at the next major sigstore-go bump, and keep the dependency out of any package butinternal/update.
- Impact 2 · Risk 2 · Effort 3 → priority 12
- Where: favthumbs.go (
gridSink), sync.go. - Partially mitigated since first noted:
gridSink.Storestops offering to the in-memory cache atThumbCacheFull(), so the pass no longer churns its own entries. What remains: a pass over a huge favorite (50k files) still decodes everything for the disk cache atsyncConcurrency = 4, competing for CPU/IO with whatever the user is doing, and the head-fills- the-budget strategy assumes the user will browse the favorite's head next. - Options (open design question, not a bug): cap the prewarm's share of the thumb budget; idle-priority workers; skip the in-memory offer entirely above a set size and rely on the disk cache.
- Impact 2 · Risk 2 · Effort 3 → priority 12
- Where: keys.go:70–338. The dispatcher itself is
flat and mostly commentary — length is not the issue. The issue is that
mode-composition rules (slideshow vs. grid vs. inspect vs. scan/sort
cancellation) are encoded positionally: an Escape priority chain plus
per-key
v.dupes.Inspecting()/slides.Active()exceptions insideP,G, andD. Each new mode multiplies cases. Item 2 (now resolved) moved where this state lives, not how these guards are structured — the positional encoding is unchanged; if tackled alone, a small mode-precedence table centralizes the rules.
- Impact 2 · Risk 2 · Effort 4 → priority 8
winpos27.9%,filepicker49.4%,wallpaper61.7%,trash66.1%,clipboard66.9% — native-API glue the fyne test driver cannot reach. Acceptable as long as the seams stay thin; keep logic out of these packages so the uncovered surface stays pure OS calls.
- Impact 1 · Risk 2 · Effort 1 → priority 15
- Where: windowmenu_darwin.go:33.
Safe only while every caller stays on the AppKit main thread and never
holds two results at once — neither constraint is written down. Return a
strdup'd copy freed by the Go side (or document the constraint at the function).testKeepAlivealso grows unboundedly, though it is test-only.
Alongside feature work, in this order:
- Now (minutes–hours): 12 — mechanical cleanup; and set a recurring
check on the heic release for item 1 (or land the
replace). - Done (2026-08-28, staged): 5 (menu recompute) and the rest of 6
(cache eviction into
appState) — onesyncMenus()replaced the per-site Checked/Disabled push, andappStatenow evicts the image cache itself on removal via anonRemovehook. Both shrank the viewer's surface and cleared the way for the field-cluster extraction below. See their own resolved notes above andtodos.mdDone → Internal. - Done (2026-08-27, staged): 2 + 4 — moved the visibility/grouping
model out of the grid into
internal/dupes, then boxed the hash engine intogrid/hashengine.go. See their own resolved notes above andtodos.mdDone → Internal. - Done (2026-08-28, staged): 3 — the
viewergod object (by the time it closed, already filed intodos.md's TODO rather than here — see the retirement note above). Four field-cluster extractions,internal/ui/menus/autoupdate/infoview/display, tookviewerfrom 87 fields to 55. Seetodos.mdDone → Internal. - Watch list: 1 (bump on release), 7 (revisit at next sigstore-go major), 8 (design decision when favorites grow), 10 (keep seams thin).