# Coding Conventions **Analysis Date:** 2026-02-26 ## Go Code Style ### Package Documentation Every package begins with a doc comment ending with a period. Use `// Package .` format: ```go // Package player provides audio playback functionality. package player // Package queue manages the playback queue and auto-advance logic. package queue // Package events contains centralized event name constants for // Wails frontend/backend communication. These names must match // the corresponding event names in the TypeScript frontend. package events ``` Enforced by `godot` linter. Multi-line doc comments are acceptable: ```go // Package profiling provides dev-only performance profiling via pprof and runtime/trace. // // In dev builds (build tag "dev"), Start launches an HTTP server on localhost:6060... package profiling ``` ### Import Organization Three groups separated by blank lines, enforced by `gci` formatter: 1. **Standard library** (e.g., `context`, `fmt`, `log/slog`) 2. **Third-party** (e.g., `github.com/...`) 3. **Internal** (prefix `yellowjacket/...`) ```go import ( "context" "errors" "fmt" "log/slog" "sync" "github.com/gopxl/beep/v2" "github.com/wailsapp/wails/v2/pkg/runtime" "yellowjacket/backend/database" "yellowjacket/backend/events" "yellowjacket/backend/metadata" ) ``` Use import aliases sparingly and only when needed to resolve conflicts: ```go import ( wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime" goruntime "runtime" ) ``` Blank identifier imports for side effects include a comment: ```go import ( _ "modernc.org/sqlite" // Register sqlite driver. ) ``` ### Error Handling **Wrap errors with context** using `fmt.Errorf` and `%w`: ```go return fmt.Errorf("failed to open file: %w", err) return fmt.Errorf("could not connect to sqlite database: %w", err) ``` **Define sentinel errors as package-level vars** (enforced by `err113`). Never use `errors.New()` inline in return statements: ```go // Exported sentinels for external consumers: var ErrUnsupportedFileType = errors.New("unsupported file type") // Unexported sentinels for internal use: var ( errNoControlStreamer = errors.New("no control streamer") errNoAudioFileLoaded = errors.New("no audio file loaded") errNoStreamerToPlay = errors.New("no streamer to play") errLibraryDirNotConfigured = errors.New("library directory not configured") ) ``` **Use `errors.Join()`** for accumulating multiple non-fatal errors: ```go var batchErr error for _, result := range batch { if saveErr := l.saveAudioFile(...); saveErr != nil { batchErr = errors.Join(batchErr, saveErr) } } ``` **Return early on errors** with a blank line after the early-return block (enforced by `nlreturn`): ```go if err != nil { return fmt.Errorf("failed to open file: %w", err) } // continue with normal flow ``` ## Naming Conventions ### Exported vs Unexported - **Structs/types**: `PascalCase` for exported, `camelCase` for unexported - **Functions/methods**: `PascalCase` for exported, `camelCase` for unexported - **Constants**: `PascalCase` for exported, `camelCase` for unexported - **Variables**: `PascalCase` for exported, `camelCase` for unexported ### Custom Domain Types Use typed aliases for domain-specific values rather than raw primitives: ```go // backend/player/volume.go type UserVolume int type Volume float64 // backend/player/player.go type State string // backend/metadata/metadata.go type AudioFileExtension string // backend/queue/queue.go type RepeatMode string // backend/library/config.go type Directory string type ScanConcurrency string ``` ### No Stuttering (enforced by `revive`) Exported types must not repeat the package name. Consumers write `queue.Track`, not `queue.QueueTrack`: ```go // Good — in package queue: type Track struct { ... } type State struct { ... } // Bad — would stutter: type QueueTrack struct { ... } type QueueState struct { ... } ``` ### Constants Group related constants with `const (...)`: ```go const ( Playing State = "playing" Paused State = "paused" Stopped State = "stopped" ) const ( MinUserVol UserVolume = 0 MaxUserVol UserVolume = 100 DefaultUserVol UserVolume = 50 ) ``` ### JSON Tags Use `camelCase` JSON tags on exported struct fields for frontend serialization: ```go type TrackInfo struct { FileName string `json:"fileName"` FilePath string `json:"filePath"` State State `json:"state"` TrackLength int `json:"trackLength"` TrackChangeID uint64 `json:"trackChangeId"` } ``` ## Constructor Pattern Use `New*` constructors with dependency injection. Accept `*slog.Logger` and scope it with `logger.WithGroup()`: ```go // backend/queue/queue.go func NewQueue(logger *slog.Logger, db *database.DB) *Queue { return &Queue{ logger: logger.WithGroup("queue"), db: db, repeatMode: RepeatOff, } } // backend/player/player.go func NewPlayer(logger *slog.Logger, db *database.DB) *Player { return &Player{ logger: logger, db: db, state: Stopped, baseStreamer: generators.Silence(-1), format: beep.Format{ SampleRate: speakerSampleRate, }, } } // backend/database/database.go func NewDB(logger *slog.Logger) (*DB, error) { // ... return &DB{ db: db, Ctx: dbCtx, Queries: queries, logger: logger, }, err } ``` Logger scoping with `.WithGroup()` or `.With()`: ```go logger.WithGroup("queue") logger.WithGroup("player") logger.WithGroup("config").With("config", conf) ``` ## SetContext Pattern (Two-Phase Initialization) Components needing the Wails runtime use two phases because the runtime is unavailable until `OnStartup`: 1. **Phase 1**: `New*()` constructor — created before `wails.Run` for binding registration 2. **Phase 2**: `SetContext(ctx context.Context)` — called after runtime starts; registers event handlers, restores state ```go // Phase 1: in NewYellowJacketApp() yjApp.player = player.NewPlayer(yjApp.logger.WithGroup("player"), yjApp.database) yjApp.queue = queue.NewQueue(yjApp.logger, yjApp.database) // Phase 2: in OnStartup() yj.player.SetContext(ctx) yj.queue.SetContext(ctx) yj.library.SetContext(ctx) yj.appConfig.SetContext(ctx) ``` SetContext implementations vary by component: ```go // backend/player/player.go — restores persisted state func (p *Player) SetContext(ctx context.Context) { p.mu.Lock() p.ctx = ctx p.mu.Unlock() p.mu.Lock() p.restoreStateLocked() p.mu.Unlock() } // backend/queue/queue.go — simple context assignment func (q *Queue) SetContext(ctx context.Context) { q.ctx = ctx } // backend/library/library.go — registers event handlers func (l *Library) SetContext(ctx context.Context) { l.ctx = ctx l.registerEventHandlers() } ``` ## Logging Conventions Use `log/slog` with structured key-value pairs. Logger injected via constructors and scoped with `WithGroup`: ```go // Info-level with structured data: p.logger.Info("File loaded, state set to paused", "file", filePath) p.logger.Info("Player state saved", "volume", volume, "muted", muted, "trackPath", trackPath, "positionSeconds", positionSeconds, ) // Error-level: p.logger.Error("Failed to decode", "path", filePath, "err", err) // Warning-level: p.logger.Warn("failed to close previous audio file", "err", closeErr) // Debug-level: p.logger.Debug("attempting to seek", "target-seconds", targetSeconds, "song-length", lengthSecs, "samples", samples, ) ``` **sloglint enforces**: consistent key-value pair formatting. Always use string keys and structured values. ### Operation Timing Use `profiling.TimeOp` (dev-only, no-op in production) with defer: ```go defer profiling.TimeOp(p.logger, "player.LoadFile")() defer profiling.TimeOp(logger, "database.NewDB")() defer profiling.TimeOp(q.logger, "queue.SetQueue")() ``` ## Comment & Documentation Requirements ### Doc Comments (enforced by `godot`) All doc comments on exported types and functions must end with a period: ```go // Player handles audio playback and state management. type Player struct { ... } // NewPlayer creates a player. Call InitSpeaker separately to // initialize the audio output device. func NewPlayer(logger *slog.Logger, db *database.DB) *Player { // SetVolume sets the playback volume (0-100), emits a // VolumeChanged event, and persists the new level. func (p *Player) SetVolume(desiredVolume UserVolume) { ``` ### Section Comments Use separator comments to organize large files into logical sections: ```go // --------------------------------------------------------------- // Emit helpers (must be called with p.mu held) // --------------------------------------------------------------- // --------------------------------------------------------------- // Streamer management (must be called with p.mu held) // --------------------------------------------------------------- // --------------------------------------------------------------- // LoadFile // --------------------------------------------------------------- ``` ### Internal Implementation Comments Unexported functions get concise comments explaining purpose and lock requirements: ```go // saveState is the internal helper that writes the current player // state to the database. Must be called with p.mu held. func (p *Player) saveState() { ``` ## Linting Rules ### golangci-lint v2 Configuration Config: `.golangci.yml` — version 2 format with `default: standard`. **Enabled linters:** - `gocritic` — common Go pitfalls - `errorlint` — proper error wrapping with `%w` - `err113` — sentinel errors must be package-level vars - `godot` — doc comments end with periods - `revive` — Go best practices (no stuttering, etc.) - `sloglint` — consistent slog usage - `nlreturn` — blank line after early returns - `wsl` — whitespace linting (cuddled declarations) - `perfsprint` — prefer `strconv` over `fmt.Sprintf` for simple conversions - `misspell` — spelling in comments - `nakedret` — no naked returns in long functions - `dupword` — duplicated words in comments - `whitespace` — trailing whitespace - `usetesting` — prefer `t.Context()` and `t.TempDir()` **Enabled formatters:** - `gci` — import ordering (stdlib → third-party → `yellowjacket/`) - `gofmt`, `gofumpt` — standard formatting - `goimports` — import management - `golines` — line length (keep under 100 characters) ### Common Linting Pitfalls **Line length (`golines`)** — Keep under 100 characters. Break long function calls: ```go // Bad — over 100 characters: q.logger.Warn("Current index out of range", "index", q.currentIndex, "trackCount", len(q.tracks)) // Good — broken across lines: q.logger.Warn( "Current index out of range", "index", q.currentIndex, "trackCount", len(q.tracks), ) ``` **Blank line after early returns (`nlreturn`)** — An `if` block ending with `return`/`continue`/`break` must be followed by a blank line: ```go if err != nil { return err } doNextThing() ``` **Cuddled declarations (`wsl`)** — `var` and `const` must be separated from preceding statements by a blank line: ```go // Good: wasEmpty := len(q.tracks) == 0 var newTracks []Track // Bad: wasEmpty := len(q.tracks) == 0 var newTracks []Track ``` **Sentinel errors (`err113`)** — Never use `errors.New(...)` or `fmt.Errorf("...")` inline in returns. Define package-level sentinels: ```go var errNotFound = errors.New("not found") ``` **Doc comments (`godot`)** — End with a period: ```go // Track represents a track in the queue with its metadata. type Track struct { ... } ``` **Stuttering (`revive`)** — Don't repeat the package name in type names. ## Concurrency Patterns ### Mutex Usage Use `sync.Mutex` with `Lock()/defer Unlock()` for public methods. Internal `*Locked` suffix functions assume lock is held: ```go // Public method acquires lock: func (p *Player) Play() error { p.mu.Lock() defer p.mu.Unlock() // ... } // Internal helper — caller must hold p.mu: func (p *Player) loadFileLocked(filePath string) error { // no lock acquired here } ``` Document lock ordering in struct comments: ```go // Player handles audio playback and state management. // // Lock ordering: always acquire p.mu BEFORE speaker.Lock(). type Player struct { mu sync.Mutex // ... } ``` ### Atomic Counters Use `atomic.Int64` for cross-goroutine counters that don't need mutex protection: ```go var added, skipped, updated atomic.Int64 added.Add(1) metrics.Added = added.Load() ``` ## Build Tags Dev/prod detection via `internal/dev/`: - `internal/dev/devbuild.go`: `//go:build dev` → `IsDev = true` - `internal/dev/nondevbuild.go`: `//go:build !dev` → `IsDev = false` Package-level functions use this for conditional behavior (e.g., `profiling.TimeOp` is a no-op in prod builds). --- ## TypeScript/Lit Conventions ### Component Pattern Use `@customElement` decorator with `LitElement` base class: ```typescript @customElement('now-playing') export class NowPlaying extends LitElement { // ReactiveControllers for store connection private player = new PlayerController(this); private favCtrl = new FavoritesController(this); // Component-local reactive state @state() private isDragging = false; // Static styles (override keyword required) static override styles = css` :host { display: block; } `; // Lifecycle (override keyword required) override connectedCallback() { super.connectedCallback(); // setup } override disconnectedCallback() { super.disconnectedCallback(); // cleanup } override render() { return html`...`; } // Private event handlers as arrow functions private handleMouseDown = (e: MouseEvent) => { e.preventDefault(); this.isDragging = true; }; private handleCoverMouseEnter = () => { // ... }; } // Register in global element map declare global { interface HTMLElementTagNameMap { 'now-playing': NowPlaying; } } ``` **Key rules:** - `override` keyword required on all lifecycle methods (`noImplicitOverride: true`) - Private event handlers as arrow functions (auto-bound `this`) - `@state()` decorator for component-local reactive state - `static override styles` for CSS-in-JS with `css` tag ### Store Pattern (Singleton + ReactiveController) Backend is source of truth. Frontend stores cache backend state via Wails events. **Store** (`frontend/src/store/player-store.ts`): ```typescript class PlayerStore { private state: PlayerState = { isPlaying: false, currentTrack: null, volume: 50 }; private subscribers = new Set(); constructor() { this.initializeEventListeners(); } private initializeEventListeners(): void { EventsOn(Events.PlaybackStateChanged, (data: { state: string }) => { this.update({ isPlaying: data.state === 'playing' }); }); } getState(): Readonly { return this.state; } subscribe(callback: Subscriber): () => void { ... } private update(partial: Partial): void { ... } private notify(): void { ... } } // Singleton instance export const playerStore = new PlayerStore(); ``` **Controller** (`frontend/src/store/controllers/player-controller.ts`): ```typescript export class PlayerController implements ReactiveController { private host: ReactiveControllerHost; private unsubscribe?: () => void; constructor(host: ReactiveControllerHost) { this.host = host; host.addController(this); } hostConnected(): void { this.unsubscribe = playerStore.subscribe(() => { this.host.requestUpdate(); }); } hostDisconnected(): void { this.unsubscribe?.(); } // Convenience getters get isPlaying(): boolean { return this.state.isPlaying; } get currentTrack(): TrackInfo | null { return this.state.currentTrack; } } ``` ### Import Organization Use path aliases from `frontend/tsconfig.json`. Use `import type` for type-only imports (`verbatimModuleSyntax`): ```typescript // Third-party import { LitElement, html, css, nothing } from 'lit'; import { customElement, state } from 'lit/decorators.js'; // Runtime/generated bindings import { EventsOn, EventsEmit } from '@runtime/runtime'; import * as Player from '@go/player/Player'; // Internal stores/controllers import type { TrackInfo } from '@store/player-store'; import { PlayerController } from '@store/controllers/player-controller'; // Components import '@components/audio-player/audio-player'; ``` **Available aliases:** - `@go/*` → `./wailsjs/go/*` (Wails-generated Go bindings) - `@components/*` → `./src/components/*` - `@store/*` → `./src/store/*` - `@runtime/*` → `./wailsjs/runtime/*` (Wails runtime) - `@utils/*` → `./src/utils/*` - `@assets/*` → `./src/assets/*` - `@pages/*` → `./src/pages/*` ### TypeScript Strictness Configured in `frontend/tsconfig.json`: - `strict: true` — all strict checks - `noUncheckedIndexedAccess: true` — array/object index checks - `noImplicitOverride: true` — require `override` keyword - `verbatimModuleSyntax: true` — require `import type` - `noUnusedLocals: true`, `noUnusedParameters: true` - `noImplicitReturns: true` - `noFallthroughCasesInSwitch: true` - `experimentalDecorators: true` — for Lit decorators - `useDefineForClassFields: false` — for Lit property definitions - Plugins: `ts-lit-plugin`, `typescript-lit-html-plugin` ### Event System Events bridge Go backend and TypeScript frontend. Names must match **exactly** in both files: - Go: `backend/events/events.go` - TypeScript: `frontend/src/events.ts` ```go // Go constants const ( PlaybackStateChanged = "PlaybackStateChanged" TrackChanged = "TrackChanged" QueueChanged = "QueueChanged" ) ``` ```typescript // TypeScript constants (as const object) export const Events = { PlaybackStateChanged: "PlaybackStateChanged", TrackChanged: "TrackChanged", QueueChanged: "QueueChanged", } as const; export type EventName = (typeof Events)[keyof typeof Events]; ``` ### Store Barrel File `frontend/src/store/index.ts` re-exports stores and types: ```typescript export { playerStore } from './player-store'; export type { PlayerState, TrackInfo } from './player-store'; export { PlayerController } from './controllers/player-controller'; ``` --- *Convention analysis: 2026-02-26*