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.
- Formatter: 2-space indent, single quotes, semicolons, trailing comma all, 100-col line width. Runs across
src/,tests/, andpackages/app/. - Linter: only a hand-picked subset is enabled as errors today (correctness + style + selected complexity rules).
recommended: false— we ramp rules in incrementally rather than turning them all on at once. Seebiome.jsoncfor the current set. - Pre-commit hook:
simple-git-hooks+lint-stagedrunbiome check --writeonly on staged files. SetSKIP_SIMPLE_GIT_HOOKS=1to bypass for emergencies. - CI: a fast
biomejob runsbiome ci --diagnostic-level=errorand gates the heavierimpact-reportandapp-typecheckjobs. - Editor:
.vscode/extensions.jsonrecommends the officialbiomejs.biomeextension. JetBrains users can install the Biome plugin. git blame:.git-blame-ignore-revslists the formatter mass-pass commit. Enable locally withgit config blame.ignoreRevsFile .git-blame-ignore-revs. GitHub honors it on the web.
Ramping new lint rules
When promoting a new rule:
- Add it to
biome.jsoncat severitywarnfirst to see the blast radius (pnpm exec biome lint --reporter=summary). - If the rule has a safe auto-fix, run
pnpm exec biome lint --write --only=<rule-id>. Review the diff. - For unsafe fixes (e.g.
useExhaustiveDependenciesremoving deps,useButtonTypeguessingtype="button"): hand-fix or scope viaoverridesinbiome.jsonc. - Once violations hit zero, promote severity to
error. - 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:
- Python parsers (
src/indexer/plugins/integration/{framework/fastapi,framework/flask,orm/sqlalchemy}/index.ts) — tree-sitterTSNodeshape varies per language; the existinganycasts should become discriminated unions over nodetype. - CLI surface (
src/cli.ts) — Commander.js untypedoptsobjects; should be replaced with per-commandinterface CliOpts. - Analytics store (
src/analytics/) —better-sqlite3row callbacks;Rowtypes should be defined per query. - Doc/refactoring tools (
src/tools/{project,refactoring,framework,analysis,quality}/*) — generic graph visitor patterns; need per-visitor type unions.
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:
- npm exposes no per-country download data for a package. Nothing to read. Don’t go looking again.
- GitHub traffic gives referrers, not geography —
Google,reddit.com,trace-mcp.com, oneyandex.ru. Useless for this question. - Self-reported profile locations were all that was left: of 102 stargazers, 31 disclose
one. Largest cluster Russian-speaking (Moscow ×3, Kiev, Bishkek), then China ×2 (plus
a fork by
iflow-mcp, a Chinese MCP tooling account), then a Spanish-speaking scatter (Tijuana, plus the forkscomputo-experto,cerebrotecnologico,felipecordero). German: one stargazer, one fork.
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 macOS build packages both architectures in one job. electron-builder
writes one
latest-mac.ymlper invocation listing only that invocation’s files, so a build matrix would have each leg clobber the other’s feed and strand one architecture. electron-updateris the app’s only productiondependency. Everything the renderer imports is bundled by Vite and therefore belongs indevDependencies— that is what keeps the packagednode_modulessmall.
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
- Create a directory under the appropriate category in
src/indexer/plugins/integration/:
src/indexer/plugins/integration/framework/my-framework/
├── index.ts
└── helpers.ts (optional)
- 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 [];
},
};
-
Register the plugin in
src/indexer/plugins/integration/framework/index.ts(or the appropriate category index). -
Write tests in
tests/frameworks/my-framework.test.ts.
Adding a new language plugin
- Create files in
src/indexer/plugins/language/my-lang/:
src/indexer/plugins/language/my-lang/
├── index.ts
└── helpers.ts
-
Use tree-sitter for parsing. See existing plugins for patterns (e.g.,
typescript/index.ts). -
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