[trace-mcp]

Development

Setup

git clone https://github.com/nikolai-vysotskyi/trace-mcp.git
cd trace-mcp
pnpm install
pnpm run build

Scripts

Script What it does
pnpm run build TypeScript compilation via tsup
pnpm run dev Watch mode (tsup –watch)
pnpm run test Run all tests (vitest)
pnpm run test:watch Watch mode for tests
pnpm run typecheck TypeScript type checking (tsc --noEmit)
pnpm run lint Same as typecheck (legacy alias)
pnpm run format Auto-format the repo with Biome
pnpm run format:check Check formatting without writing
pnpm run biome:ci Full Biome check (formatter + linter) — same as CI
pnpm run serve Start MCP server (dev)
node scripts/capture-screenshots.mjs Regenerate every docs/site screenshot from a seeded demo state
pnpm --filter trace-mcp-app run check:i18n Fail on a user-facing string left inline in an extracted surface

Code style — Biome

Formatter and linter are unified under Biome. Config lives in biome.jsonc at the repo root.

Ramping new lint rules

When promoting a new rule:

  1. Add it to biome.jsonc at severity warn first to see the blast radius (pnpm exec biome lint --reporter=summary).
  2. If the rule has a safe auto-fix, run pnpm exec biome lint --write --only=<rule-id>. Review the diff.
  3. For unsafe fixes (e.g. useExhaustiveDependencies removing deps, useButtonType guessing type="button"): hand-fix or scope via overrides in biome.jsonc.
  4. Once violations hit zero, promote severity to error.
  5. Mass-fix commits should be added to .git-blame-ignore-revs.

Remaining warning burndown

pnpm run biome:ci exits clean (0 errors). The remaining warnings are the noExplicitAny backlog (~170, scoped to src/ and packages/app/ — tests are overridden to off because mocks and AST fixtures intentionally use any).

These should be fixed incrementally as files are touched, and require real domain types — not blanket replacement with unknown:

Promote suspicious/noExplicitAny from warn to error once the backlog is gone.

Tests

pnpm run test                       # All tests (1668 tests, ~2s)
pnpm run test --run <pattern>  # Run specific test files
pnpm run test:watch             # Watch mode

Test files live alongside source or in tests/:

tests/
├── ai/              # AI pipeline tests
├── ci/              # CI report generator and formatter tests
├── frameworks/      # Framework plugin tests (per-framework)
├── tools/           # MCP tool integration tests
├── integration/     # End-to-end indexing tests
├── e2e/             # CLI and protocol tests
├── db/              # Database layer tests
├── indexer/         # Indexing pipeline tests
├── parsers/         # Language parser tests
├── resolvers/       # Module resolver tests
├── scoring/         # Scoring algorithm tests
└── fixtures/        # Test fixtures (sample projects)

Desktop app strings and languages

The app is translated (TRA-379). Every user-facing string lives in a catalogue, not in the component that renders it, and English is the source language.

packages/app/src/shared/i18n/
  locales.ts              # which languages ship, their names, the localStorage key
  catalog/en/<surface>.ts # the strings, one file per surface (= one i18next namespace)
  catalog/ru/<surface>.ts # a translation, same keys — one such directory per language
packages/app/src/renderer/i18n/
  index.ts                # i18next init, setLocale, useLocale, t
  format.ts               # Intl wrappers: relativeTime, formatDate, formatNumber
packages/app/src/main/
  i18n.ts                 # the main process's own i18next instance, and its t
  locale.ts               # the choice mirrored to userData, so main can read it

Why i18next. Plurals. Russian needs four forms where English needs two, and the only correct way to choose one is Intl.PluralRules — which i18next drives, along with interpolation and a runtime language switch. We install the resolver and none of its optional backends or detectors, because the catalogues are compiled in: a desktop app should not wait on a fetch to paint its first label.

Adding a string. Put it in catalog/en/<surface>.ts (create the file and add one line to catalog/en/index.ts if the surface is new — one file per surface is what keeps two extraction slices from editing the same catalogue), add the same key to every other language, then read it in the component:

const { t } = useTranslation('settings');   // components: re-renders on a switch
t('title');
t('projectCount', { count });                // plurals: one key, never a ternary

Module-level helpers that are not components import t from renderer/i18n instead. Never concatenate a sentence, and never format a date or a number by hand — use renderer/i18n/format.ts.

Which languages ship. Ten: en · de · es · fr · hi · ja · ko · pt-BR · ru · zh. English is first because it is the source language and the fallbackLng; the rest are ordered by code. An order that encodes importance only invites the argument about the order.

The set is weighted to a developer audience rather than to general speaker counts — that is why Chinese, Japanese and Korean are in it. English stays the source because every issue and discussion this repo has is in English.

What the evidence actually supported, and where it ran out (TRA-389). Worth keeping, because the next person to ask “who are our users” will otherwise re-run these searches:

That signal is thin, and on its own it supported exactly the four languages TRA-389 shipped. The set is ten because #594 chose to weight the developer audience instead of waiting for evidence this project cannot collect — a judgement call, made knowingly, not a reading of the data above. Note the cost it accepted: every language is a permanent commitment on every future string, and catalog-parity.test.ts will enforce it on ten catalogues from here on.

Adding a language. Add it to LOCALES in shared/i18n/locales.ts, copy catalog/en/ to catalog/<code>/ and translate it. catalog-parity.test.ts then fails until every key exists and every `` survived; nothing else needs wiring, and the Language control picks the new entry up from LOCALES.

Two things a copy-and-translate pass gets wrong. Plurals are per-language: write the forms the language actually has, not a mirror of English’s _one/_other. Chinese has one (_other alone), Russian has four. The parity test compares base keys precisely so that it cannot force a language into English’s shape. And length: German and Spanish run longer than English, so check the workspace table headers, the bulk actions bar and the segmented controls at the 640×420 window minimum.

The checks.

pnpm --filter trace-mcp-app run check:i18n   # no inline strings in extracted surfaces
pnpm --filter trace-mcp-app run test         # catalogue parity, plurals, Intl output

check-i18n.mjs scans an allowlist, not the whole tree: string extraction lands surface by surface, and the CHECKED array at the top of the script is how a finished slice records that it is finished. Extract a surface → add its path there.

The main process (the application menu, the tray, dialogs) has no React and cannot read the renderer’s localStorage, so the language is mirrored to a one-line file in userData — exactly the arrangement main/appearance.ts uses for the theme. The renderer’s setLocale sends set-locale over IPC, and main/menu.ts writes the file, switches its instance and rebuilds both surfaces: Menu.setApplicationMenu replaces the menu wholesale, there is no per-item relabel. Main-process code calls t('menu:file') from main/i18n. Standard macOS items stay on their Electron role — the OS supplies those labels already translated, and hand-translating one is how a menu ends up half in each language.

Desktop app update channels

packages/app/src/main/update-channel.ts is the single place that decides which mechanism a platform gets. There is one mechanism now; there used to be two.

Platform Mechanism Notes
macOS electron-updater + Squirrel.Mac Driven by latest-mac.yml. Squirrel.Mac validates the replacement bundle’s code signature, so this only became possible once builds were Developer ID signed and notarized (TRA-436).
Windows electron-updater + NSIS Driven by latest.yml.
Linux none No packaged target today (linux.target: []).

Both channel files come from the top-level publish block in packages/app/electron-builder.yml and are uploaded to the GitHub release by .github/workflows/release.yml, which fails the release if either is missing — an install polling a 404 is never offered another update and says nothing.

Consequences worth knowing before touching this:

The staged-zip updater, and the bridge off it (TRA-437)

macOS used to run a second mechanism: the npm postinstall downloaded the release zip and replaced the .app itself, staging the zip beside the bundle when the app was running so a helper could swap it on exit. It failed fourteen consecutive times without a single success (TRA-431) and is gone — along with scripts/apply-pending-update.mjs, the pending marker files, and ~/.trace-mcp/app-update-state.json.

Builds up to and including 3.8.0 are ad-hoc signed and cannot self-update, so scripts/postinstall-app.mjs still swaps those bundles — and only those. It recognises them by the presence of Contents/Resources/scripts/apply-pending-update.mjs, which shipped for exactly as long as the old updater existed. A bundle without it owns its own updates and is never written from outside; a version constant would have to be kept in sync with whatever release-please picks, and this cannot drift.

Once no legacy bundle is left in the field, everything in that script below stopRunningDaemon() can be deleted.

That script keeps one invariant worth knowing before touching it: only an installed bundle may become the update target. An electron-builder output under release/mac-arm64/ is a real, correctly signed-looking bundle, so plist validation alone accepts it; isPlausibleInstallPath (duplicated in scripts/locate-app.mjs and packages/app/src/main/install-path.ts, kept honest by install-path.test.ts) is what rejects build trees and checkouts. Recording one in ~/.trace-mcp/app-location.json froze a user’s install for three major versions.


Adding a new integration plugin

  1. Create a directory under the appropriate category in src/indexer/plugins/integration/:
src/indexer/plugins/integration/framework/my-framework/
├── index.ts
└── helpers.ts (optional)
  1. Implement FrameworkPlugin:
import { FrameworkPlugin, PluginManifest } from '../../../../plugin-api/types.js';

const manifest: PluginManifest = {
  name: 'my-framework',
  version: '1.0.0',
  languages: ['typescript'],
  priority: 20,
};

export const MyFrameworkPlugin: FrameworkPlugin = {
  manifest,

  detect(ctx) {
    // Check package.json, config files, etc.
    return ctx.hasDependency('my-framework');
  },

  registerSchema() {
    return {
      nodeTypes: ['my_framework_route'],
      edgeTypes: ['my_framework_handles'],
    };
  },

  extractNodes(filePath, content, language) {
    // Parse file and return symbols
    return { symbols: [], edges: [] };
  },

  resolveEdges(ctx) {
    // Resolve cross-file relationships
    return [];
  },
};
  1. Register the plugin in src/indexer/plugins/integration/framework/index.ts (or the appropriate category index).

  2. Write tests in tests/frameworks/my-framework.test.ts.


Adding a new language plugin

  1. Create files in src/indexer/plugins/language/my-lang/:
src/indexer/plugins/language/my-lang/
├── index.ts
└── helpers.ts
  1. Use tree-sitter for parsing. See existing plugins for patterns (e.g., typescript/index.ts).

  2. Register in src/indexer/plugins/language/index.ts.


Plugin test harness

The src/plugin-api/test-harness.ts module provides utilities for testing plugins in isolation:

import { createTestHarness } from '../src/plugin-api/test-harness.js';

const harness = createTestHarness(MyPlugin);
const result = await harness.indexFile('test.ts', sourceCode);
expect(result.symbols).toContainEqual(expect.objectContaining({ name: 'myFunction' }));

Screenshots — one script, one seeded state

Every screenshot in README.md and on trace-mcp.com is produced by scripts/capture-screenshots.mjs. Do not take them by hand: hand-taken shots carry whatever happened to be on the machine — a developer’s own project list, a Daemon unreachable banner, half-loaded skeletons — and nothing records what version of the app they show.

pnpm run build                       # the CLI bundle the demo daemon runs from
pnpm --dir packages/app run build    # the renderer being photographed
node scripts/capture-screenshots.mjs             # regenerate everything
node scripts/capture-screenshots.mjs app-graph   # just one (marker left alone)
node scripts/capture-screenshots.mjs --now       # …without waiting for an idle machine
node scripts/capture-screenshots.mjs --check     # are the committed ones stale?

The run launches the real Electron window against a seeded demo state and writes WebP files into docs/images/. It does not touch the daemon you already have running, your ~/.trace-mcp, or your project registry: the demo daemon gets its own port and its own TRACE_MCP_DATA_DIR, the demo projects are git archive extracts of this repo at HEAD placed under /tmp/trace-mcp-demo, and Electron gets a throwaway Chromium profile. Nothing in the frame identifies a machine or a person.

The frame is a photograph of the window, not of the web contents. macOS draws the traffic lights, the rounded corners and the sidebar’s vibrancy outside the renderer, so Page.captureScreenshot — the obvious way to do this — returns something indistinguishable from a browser tab, and that is what got published once (TRA-390). Instead the script asks the main process for the window’s CGWindowID over its Node inspector and hands it to screencapture -o -l<id>: the real window, no drop shadow, rounded corners returned as alpha. This makes the script macOS-only, and it steals focus for the length of the run — the window has to be key, or the buttons photograph grey.

So it waits until nobody is at the machine. Owning the screen is not optional here and both ways out were measured and rejected: webContents.capturePage() on an unshown window returns square opaque corners and no buttons, and showInactive() plus screencapture returns the corners but grey buttons — both are frames checkWindowChrome refuses. What the run can do is not take the screen from somebody: it reads HIDIdleTime and defers (exit code 75, nothing written) unless the machine has been untouched for five minutes, and it activates the app once per run rather than once per shot. Pass --now when you are the one asking for it and are willing to lose the front for a couple of minutes.

Every frame is inspected before it becomes a file. checkWindowChrome looks for the two things a capture of the web contents can never have — transparent rounded corners, and the three buttons in colour in the top-left strip — and throws with the reason when either is missing. A chrome-less capture fails the run instead of quietly replacing a good image.

Adding a screenshot is a data change. Append an entry to scripts/screenshots.manifest.json — the surface to open, which controls to click, the appearance, and the alt text — and re-run the script. The alt in the manifest is the same string that belongs in README.md and docs/index.html; keep them equal when a screenshot’s content changes, because stale alt text is both an accessibility bug and an SEO one.

Freshness. docs/images/screenshots.json records the app version and the commit of the last change under packages/app/src/renderer / src/main. --check compares that against HEAD and exits non-zero with a reason when the UI has moved on — that is the signal the docs and SEO autopilots read, so they never have to eyeball an image to know whether it is current.

Last updated: August 30, 2026