clone

fifteen puzzle games for the terminal, with daily puzzles, weekly ladders and xp, built on bubble tea

refactor/root-tui-model
puzzletea / openspec / changes / refactor-root-tui-model / graph-analysis.md
graph-analysis.md 13.2K rendered
1# Graph Analysis: refactor-root-tui-model23> Generated from GitNexus knowledge graph index on branch `chore/app-model-decomp`.4> Graph stats: 8,349 nodes | 33,696 edges | 490 clusters | 300 flows.5> No circular imports detected (`gitnexus check --cycles`).67## 1. Current Dispatch Topology89### Root `Update` (`app/update.go:10–68`)1011The graph confirms `Update` is a flat dispatcher with 6 outgoing call targets spanning 4 concerns:1213| Message / Branch | Handler | File | Domain |14|---|---|---|---|15| `spawnCompleteMsg` / `game.SpawnCompleteMsg` | `model.handleSpawnComplete` | `app/spawn.go:85` | Session |16| `exportCompleteMsg` | `model.handleExportComplete` | `app/export.go` | Export |17| `exportSubmitAction` | `model.handleExportSubmit` | `app/export.go` | Export |18| `backAction` | `msg.applyToModel(m)` | `app/screen_core.go` | Screen action |19| `tea.WindowSizeMsg` | `model.handleWindowSize` | `app/update.go:80` | Shell/route |20| All other keys | `model.handleGlobalKey` | `app/update.go:89` | Mixed (see §3) |21| `gameView` delegation | `newSessionController(&m).updateActiveGame` | `app/session_controller.go:169` | Session |22| Screen update | `m.activeScreen().Update` → `handleScreenAction` | `app/screen_core.go` / `app/update.go:179` | Screen |2324**Trace**: `Update → newSessionController → sessionController` is 2 hops (CALLS edges, confidence 0.85). The controller is reconstructed on every `Update` call that needs it — `handleSpawnComplete`, `updateActiveGame`, and the free-function wrappers all call `newSessionController(&m)` inline.2526### `handleWindowSize` (`app/update.go:80–87`)2728Accesses `m.width`, `m.height`, `m.state`, and calls `resizeActiveScreen()`. For `gameView`, skips screen resize (game handles its own sizing via `sessionController.activateGame` which pushes `tea.WindowSizeMsg` to the `Gamer`).2930### `resizeActiveScreen` (`app/update.go:71–78`)3132Reads `m.screens[m.state]`, calls `screen.Resize(m.width, m.height)`, writes back. This is the only screen-cache mutation path besides `initScreen`.3334---3536## 2. Screen Route Management3738### `screenModel` interface (`app/screen_core.go:234–238`)3940```41Resize(width, height int) screenModel42Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction)43View(notice noticeState) string44```4546### Screen registry (`app/screen_core.go:242–288`)4748`screenRegistry` maps all 15 `viewState` values to `screenFactory` functions. Factories read from `model` fields (`m.nav`, `m.seed`, `m.create`, `m.cont`, `m.weekly`, `m.help`, `m.stats`, `m.theme`, `m.spinner`) — screens are constructed from shell state, confirming the design's statement that screens are "dependency-light" (they receive data via construction, not via injected references).4950### `activeScreen()` (`app/screen_core.go:290–291`)5152Called from 2 sites (graph-confirmed):53- `model.Update` (`app/update.go:45`) — for screen message dispatch54- `model.viewContent` (`app/view.go`) — for rendering5556### `initScreen(state)` (`app/screen_core.go:297–307`)575813 incoming callers (graph-confirmed):59- `InitialModel`, `InitialModelWithGame` — initialization60- 8 `screenAction.applyToModel` implementations — route transitions61- `sessionController.startSpawn`, `startEloSpawn`, `startSeededSpawn` — generating screen setup62- `enterSeedInputView` (`app/seed_input.go`)63- `handleGlobalKey` (weekly escape path) — re-inits weekly screen after save6465This confirms `initScreen` is the single entry point for screen creation, but it's called from both route-transition code (actions) and workflow code (session controller, global key handler) — the ownership overlap the refactor targets.6667---6869## 3. Global Key Ownership Leaks7071`handleGlobalKey` (`app/update.go:89–169`, 80 lines) directly accesses:7273| Access | Property | Owner (proposed) |74|---|---|---|75| `m.session.spawn` | `sessionState.spawn` | Session lifecycle |76| `m.session.returnState` | `sessionState.returnState` | Session lifecycle |77| `m.session.game` | `sessionState.game` | Session lifecycle |78| `m.help.showFull` | `helpState.showFull` | Shell |79| `m.debug.enabled` | `debugState.enabled` | Shell |8081**Outgoing calls** span 4 domains (graph-confirmed):8283- **Session**: `cancelActiveSpawn` (via `model.cancelActiveSpawn` wrapper), `saveCurrentGame` (free function wrapper), `activeSpawnReturnState`84- **Export**: `cancelActiveExport`85- **Route/screen**: `initScreen`, `resizeActiveScreen`86- **Weekly workflow**: `refreshWeeklyBrowser`, `advanceSolvedWeekly`87- **Game**: `Gamer.Reset`, `Gamer.Update` (HelpToggleMsg propagation)8889### Wrapper indirection (graph-identified)9091Three free-function / method wrappers exist solely because `handleGlobalKey` operates on `model` by value while `sessionController` needs a pointer:9293```94handleGlobalKey → saveCurrentGame (spawn.go:91) → sessionController.saveCurrentGame95handleGlobalKey → model.cancelActiveSpawn (spawn.go:81) → sessionController.cancelActiveSpawn96handleSpawnComplete → model.handleSpawnComplete (spawn.go:85) → sessionController.handleSpawnComplete97```9899Each creates a throwaway `sessionController{model: &m}`. The refactor should either:100- Have `handleGlobalKey` construct a single controller and delegate, or101- Move the key handling into the controller itself for game/generating states.102103---104105## 4. Session Controller Surface106107`sessionController` (`app/session_controller.go:14–16`) already owns 12 methods:108109| Method | Lines | Responsibility |110|---|---|---|111| `beginSpawnContext` | 23–31 | Job ID increment, cancel prev, set generating |112| `cancelActiveSpawn` | 33–43 | Cancel context, clear spawn, bump job ID |113| `startSpawn` | 45–52 | Begin → set spawn → state=generating → initScreen → cmd |114| `startEloSpawn` | 54–61 | Same pattern, Elo variant |115| `startSeededSpawn` | 63–70 | Same pattern, seeded variant |116| `handleSpawnComplete` | 72–123 | Job ID guard, stale check, activate, create record |117| `activateGame` | 138–148 | Help/ws propagation, set session fields, state=gameView |118| `importRecord` | 150–167 | Import, read-only handling, activate |119| `updateActiveGame` | 169–177 | Delegate to Gamer.Update, debug info, persist |120| `persistCompletionIfSolved` | 179–197 | Guarded save + status update |121| `saveCurrentGame` | 199–222 | Save state + status, clear active game |122| `clearActiveGame` | 224–230 | Reset session fields to defaults |123124**Process flows through the controller** (graph-confirmed):125- `Update → SessionController` (proc_200, 4 steps)126- `HandleGlobalKey → SessionController` (proc_234, 4 steps)127- `HandleDailyPuzzle → SessionController` (proc_118, 5 steps)128129The controller already covers most of the session lifecycle. The gap is that `handleGlobalKey` and `handleSpawnComplete` bypass it through wrappers instead of delegating directly.130131---132133## 5. Impact Analysis134135### Blast radius for `model` struct (`gitnexus impact`)136137- **Risk**: MEDIUM138- **Impacted symbols**: 11 (6 direct + 3 depth-2 + 2 depth-3)139- **Processes affected**: 2 (both `Update` flows in `screen_navigation.go`)140- **Modules affected**: App (8 hits, direct), Ui (1 hit, direct)141142**Direct dependents** (depth 1):143- `InitialModel` (`app/model.go`) — constructor144- `InitialModelWithGame` (`app/model.go`) — constructor145- `seedInputScreen.Update` (`app/screen_navigation.go`)146- `gameSelectScreen.Resize` (`app/screen_navigation.go`) — 5 processes147- `helpDetailScreen.Resize` (`app/screen_support.go`)148- `statsScreen.Resize` (`app/screen_support.go`)149150**Depth-2**: `runGameProgram` (`cmd/session.go`), `gameSelectScreen.resizeSelf`, `cmd/root.go`151152### Key insight153154The impact is contained within the `app` package + `cmd` entry points. No `game`, `store`, `catalog`, or `registry` package code depends on `model` internals. This confirms the design's non-goal: no public API or package boundary changes needed.155156---157158## 6. `screenAction` Pattern159160The graph identifies 8 action types implementing `screenAction`:161162| Action | File | What it does |163|---|---|---|164| `backAction` | `app/screen_core.go` | Generic back navigation |165| `openPlayMenuAction` | `app/screen_core.go` | → playMenuView |166| `openOptionsMenuAction` | `app/screen_core.go` | → optionsMenuView |167| `openCreateAction` | `app/screen_core.go` | → createView |168| `openContinueAction` | `app/screen_core.go` | → continueView |169| `gameSelectEnterAction` | `app/screen_core.go` | → gameSelectView |170| `weeklyShiftAction` | `app/screen_core.go` | → weeklyView |171| `openHelpSelectAction` | `app/screen_core.go` | → helpSelectView |172173All actions call `initScreen` to transition. None directly access `store`, `config`, or `sessionController` — confirming the design's "screens emit typed intents" pattern is already in place.174175`exportSubmitAction` (`app/screen_core.go:232`) is the exception: it's a `tea.Msg`, not a `screenAction`, and is handled directly in root `Update` (line 22-24). This is the export-workflow special case the design notes.176177---178179## 7. File Inventory (affected code)180181| File | Lines | Role |182|---|---|---|183| `app/model.go` | 281 | Model struct, viewState, state types, constructors |184| `app/update.go` | 181 | Update dispatch, handleGlobalKey, handleWindowSize, resizeActiveScreen |185| `app/screen_core.go` | 307 | screenModel interface, registry, activeScreen, initScreen, actions |186| `app/session_controller.go` | 230 | Session lifecycle (12 methods) |187| `app/spawn.go` | 94 | Spawn commands + 3 wrapper functions |188| `app/view.go` | 214 | View rendering |189| `app/export.go` | 1720 | Export workflow (screen + handlers) |190| `app/handle_create.go` | 114 | Create flow |191| `app/handle_seed.go` | 66 | Seed flow |192| `app/handle_daily.go` | 48 | Daily flow |193| `app/weekly.go` | 319 | Weekly flow + browser |194195**Total**: ~3,574 lines in the affected surface.196197---198199## 8. Refactor Recommendations (graph-derived)200201### 8.1 Route/screen manager202203Extract from `model`:204- `state viewState` → `manager.current`205- `screens map[viewState]screenModel` → `manager.cache`206- `activeScreen()` → `manager.active()`207- `initScreen(state)` → `manager.init(state, shell)`208- `resizeActiveScreen()` → `manager.resizeActive()`209- `handleWindowSize` → `manager.handleWindowSize(msg, isGameView)`210211**Graph evidence**: `activeScreen` has 2 callers (`Update`, `viewContent`), `initScreen` has 13 callers, `resizeActiveScreen` has 3 callers (`handleWindowSize`, `handleGlobalKey` ×2). Low blast radius.212213### 8.2 Session key delegation214215Move `handleGlobalKey` branches for `generatingView` and `gameView` into `sessionController`:216217- `generatingView` escape/quit → `sessionController.handleGeneratingKey(keyMsg) (model, tea.Cmd, bool)`218- `gameView` escape/quit/reset/debug/fullhelp/enter → `sessionController.handleGameKey(keyMsg) (model, tea.Cmd, bool)`219220This eliminates:221- The 3 wrapper functions in `spawn.go` (lines 81–94)222- Direct access to `m.session.*`, `m.help.showFull`, `m.debug.enabled` from `handleGlobalKey`223- The `saveCurrentGame` free function224225### 8.3 Export key delegation226227Move `exportRunningView` escape/quit from `handleGlobalKey` into export workflow code:228- `exportController.handleRunningKey(keyMsg) (model, tea.Cmd, bool)`229230Or simpler: an `exportRunningKeyHandler` function in `app/export.go` that `handleGlobalKey` calls for `exportRunningView` state.231232### 8.4 Test coverage priorities (graph-derived high-risk paths)233234Based on process flow counts and caller counts, these paths have the highest regression risk:2352361. **`initScreen` with 13 callers** — any change to screen cache behavior risks all route transitions2372. **`handleGlobalKey` game escape path** — calls `saveCurrentGame` + `refreshWeeklyBrowser` + `initScreen(weeklyView)` in sequence; touches session, weekly, and route domains2383. **`sessionController.handleSpawnComplete`** — 3 process flows depend on it; job ID guard + stale check + activate + record creation2394. **`sessionController.activateGame`** — called from `handleSpawnComplete` and `importRecord`; help/ws propagation order matters2405. **`resizeActiveScreen`** — called from 3 sites; nil-map-read safety depends on `screens` being initialized241242---243244## 9. Open Questions Resolved by Graph245246### Q1: Should create/daily/seed/weekly decision logic live in one workflow helper?247248**Graph evidence**: Each handler (`handle_create.go`, `handle_seed.go`, `handle_daily.go`, `weekly.go`) calls `sessionController.startSpawn` / `startEloSpawn` / `startSeededSpawn` independently. The call patterns are similar but the decision logic (which spawner, which spawn source, which return state) is workflow-specific. 249250**Recommendation**: Keep decision logic in current files. The session controller already owns the spawn mechanics. Consolidating decision logic would add a layer without reducing duplication, since each workflow constructs a different `spawnRequest` with different fields.251252### Q2: Should export get a small controller?253254**Graph evidence**: `export.go` is 1,720 lines — the largest file in the affected surface. `handleGlobalKey` only touches it via `cancelActiveExport`. `Update` touches it via `handleExportSubmit` and `handleExportComplete`.255256**Recommendation**: Minimal cleanup. Extract the `exportRunningView` key handling into `app/export.go` as a function. Do not build a full export controller in this change — the export workflow's size warrants its own dedicated refactor.257258---259260*Analysis performed with GitNexus v1.6.8 CLI. Graph index built on `chore/app-model-decomp` branch.*