Skip to main content

Architecture Overview

A practical map of how Reikon is put together — written for a human reading it before their first PR, not as a redirect to CLAUDE.md (which is internal guidance for Claude Code sessions specifically, and assumes that context).

Stack

Electron 32, electron-vite, React 18, Tailwind 3, Zustand for state, Phosphor Icons, TypeScript throughout (no any), Vitest for tests.

The three processes

Like any Electron app, Reikon runs across multiple processes with different responsibilities:
  • Main process (src/main/) — owns the window, the IPC handlers, the file watcher, and orchestrates analysis. Talks to disk and runs git commands; never touches the UI directly.
  • Renderer process (src/renderer/) — the React UI. Has no direct filesystem or git access — everything it needs comes through IPC.
  • Preload script (src/preload/) — the bridge between the two. Exposes a typed window.analyzer API via Electron’s contextBridge; the renderer never talks to ipcRenderer directly.
Heavy analysis work (the actual repo scanning) runs in a fourth place: a utility process (src/main/worker.ts), spawned by the main process, so a large repo’s analysis never blocks the window from responding.

Code shared between main and renderer

Most code is environment-specific by necessity — the renderer can’t touch the filesystem, the main process has no DOM. But pure calculation logic with no environment-specific dependencies can and should live in src/shared/, importable from both sides (it’s included in both tsconfig.node.json and tsconfig.web.json, the same way src/types/ already was). The health score formula is the canonical example: it used to be three independently hand-written copies (the desktop app, persisted snapshots, and the CLI), which had already drifted apart in ways nobody caught until an audit found them. It’s now one implementation in src/shared/health-score.ts, imported by all three. If you’re adding logic that more than one of needs to compute identically, this is where it goes — not copied into each consumer “to keep in sync by hand.”

How an analysis actually runs

  1. Renderer calls window.analyzer.run(rootPath)
  2. Preload forwards it over IPC to the main process
  3. Main process spawns (or reuses) the utility process worker
  4. The worker runs runAllAnalyzers() — git stats, line counts, complexity, duplicates, imports, dead code, dependency audit, coverage parsing, all in one pass
  5. Results come back as one AnalysisResult object, cached on disk keyed by repo path
    • current commit hash + a hash of the project config
  6. The renderer stores it in a Zustand store; every tab derives what it needs from that one object via a single hook (useAnalysis()) — no tab fetches its own data independently
This matters for anyone adding a new piece of data: it should flow through AnalysisResult, get added to the relevant analyzer in src/main/analyzers/, and get exposed through useAnalysis() — not fetched ad hoc from a new IPC call inside a specific tab, unless it’s something that genuinely needs to be computed on demand (see the next section).

When something isn’t part of the cached analysis

Not everything goes through the cached AnalysisResult pipeline. Some signals are computed live, on demand, via a dedicated IPC call instead — typically because they involve comparing two arbitrary git states (e.g. a staged diff vs. HEAD, or two arbitrary commits) rather than “the current state of the repo,” which is what the cached pipeline is built around. The diff health score and post-commit complexity toast in the Review tab are both built this way: a small main-process handler runs git show/git diff against the specific refs needed, instead of waiting for or triggering a full re-analysis.

IPC conventions

Every IPC handler:
  • Is wired in three places: main/index.ts (the handler itself), preload/index.ts (the exposed window.analyzer.* method), and types/ipc.ts (the AnalyzerAPI interface). Missing any one makes the method unreachable from the renderer with no compile error to catch it.
  • Never throws. Returns { __error: string } on failure; the preload layer checks for that shape and re-throws as a real Error on the renderer side, so renderer code can use normal try/catch.

The renderer’s data flow

useAnalysis() is the single hook every tab uses to read analysis data — no tab reads the Zustand store’s raw data field directly, and no tab fetches its own copy of anything already in AnalysisResult. This keeps every tab’s view of the data consistent and means a schema change to AnalysisResult only needs updating in one place to propagate everywhere it’s consumed.

MCP server

A separate, smaller process (src/main/mcp-server.ts), started with a --mcp-server flag, speaking the Model Context Protocol over stdio. It reads a lightweight mcp-context.json snapshot written after each analysis — not a live connection into the running desktop app — so an AI agent’s queries never block or interfere with whatever you’re doing in the UI. See the MCP tool reference for what it exposes.

CLI

packages/cli is a separate npm package (rei) that imports the same analyzer code directly from src/main/analyzers/ — there’s exactly one implementation of every analyzer, used by both the desktop app and the CLI. See the CLI reference.

Where to look next

  • Design system for UI conventions if you’re touching the renderer
  • Lessons learned for non-obvious gotchas worth knowing before you hit them yourself
  • CONTRIBUTING.md at the repo root for dev setup and the PR process