Files
yellowjacket/.planning/codebase/CONVENTIONS.md
T
2026-02-26 16:58:25 -05:00

716 lines
18 KiB
Markdown

# 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 <name> <description>.` 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<Subscriber>();
constructor() {
this.initializeEventListeners();
}
private initializeEventListeners(): void {
EventsOn(Events.PlaybackStateChanged, (data: { state: string }) => {
this.update({ isPlaying: data.state === 'playing' });
});
}
getState(): Readonly<PlayerState> { return this.state; }
subscribe(callback: Subscriber): () => void { ... }
private update(partial: Partial<PlayerState>): 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*