deoptkit
v0.3.0
Published
A V8 deoptimization toolkit for AI agents and dev workflows: MCP server, findings engine, benchmark harness, and fix-verify comparison for inline caches, deopts, and hidden-class churn
Maintainers
Readme
🔥 deoptkit
A V8 deoptimization toolkit for JavaScript and TypeScript on Node.js — visibility into inline cache states, deoptimizations, hidden-class (map) churn, and CPU profile data, packaged for AI agents and everyday dev workflows.
deoptkit began as a rebuild of Microsoft's Deopt Explorer VSCode extension, re-targeted at AI agents instead of humans and updated for modern V8 (tested against V8 14 / Node 26; the original supported V8 8–9). Where the extension offered tree views and editor decorations to browse, deoptkit answers the question agents actually ask: "what should I fix first?" — with ranked findings, explanations, suggested fixes, and a first-class before/after comparison to verify the fix worked.
Background reading: the TypeScript team's Introducing Deopt Explorer article, where this class of analysis produced an 8–10% compiler speedup.
What's in the kit
deoptkit ships several surfaces over one engine, all from the deoptkit package:
| Surface | What it is |
| -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| MCP server (deoptkit / deoptkit mcp) | Eleven tools + a prompt that let an AI agent profile a workload, get ranked findings, drill in, and verify fixes. |
| deoptkit ci | Snapshot-style regression gate: fails CI when a change introduces new structural findings (megamorphic ICs, deopt loops, map churn). |
| deoptkit lsp | A language server that publishes findings as inline editor diagnostics (squiggles + Problems panel). |
| deoptkit/harness | mark() / observed() / interleaved() — plant in-log window markers, warm up hot code, and time variants against each other. |
| deoptkit/vitest + deoptkit/bench | A vitest bench preset that records V8 logs per worker, a provider that runs each case a fixed number of times, and benchObserved() to window each case. |
| deoptkit/serve | driveServer() — boot an SSR app in-process and drive it with requests so its runtime is observable. |
| library API (deoptkit) | parseLog, computeFindings, compareSessions, runWorkload, … for building your own tooling. |
Quick start (AI agent + MCP)
For Claude Code the fastest path is the plugin: run /plugin marketplace add Saeris/deoptkit, then /plugin install deoptkit@deoptkit. It registers the server and adds a skill with the workflow. The manual registration below works for any MCP host.
Register the stdio server with your MCP host. For Claude Code, add to .mcp.json in your project (or run claude mcp add):
{
"mcpServers": {
"deopt": {
"command": "npx",
"args": ["-y", "deoptkit"]
}
}
}For VSCode (Copilot Chat / MCP-capable extensions), add to .vscode/mcp.json:
{
"servers": {
"deopt": { "command": "npx", "args": ["-y", "deoptkit"] }
}
}The server advertises instructions describing the workflow, so a connected agent knows what to do without reading this file. The loop:
profile_run { command: ["node", "bench.js"] }— runs the workload under V8 logging flags (no flag knowledge needed) and loads the result as a session.get_findings { sessionId }— everything wrong, ranked worst-first with severity 1–100, source locations, V8-level explanations, and suggested-fix categories.- Fix the top finding — at its
originalposition when present (that's the source you wrote), otherwise its file/line (paths are relative to the project, ready for your editor or file tools). profile_runagain, thencompare_sessions { baseSessionId, headSessionId }— reports the findings your change resolved, any regressions it introduced, and per-function CPU deltas.
Drill-down tools when a finding needs more context: get_function (annotated source + its ICs/deopts/ticks), get_map (a hidden class's transition chain and which call sites it polluted), list_ics, list_deopts, list_functions, get_profile (the call tree: who calls the hot function, top-down or bottom-up), load_log (analyze an existing v8.log), load_manifest (load every log from a vitest bench run), list_sessions.
Warm-up matters. V8 only optimizes hot code, so profile a workload that runs the target thousands of times — the
observed()helper below encodes this. For libraries, profile the built output, not source: bundling changes shapes and inlining. The server flags cold runs:profile_runandload_logreturnquality.warningswhen no user function reached an optimizing tier, no profiler ticks were recorded, or the ticks are too sparse to rank by (sparse-ticks: IC and map logging starved the sampler; profile once more withcategories: ["profile"]for the call tree).get_findingsalso lists the files on the page that have no source map underunmapped, so a bundle built without one is not mistaken for the source.
Benchmarking your own code
Standalone scripts
// bench/parse.bench.mjs
import { observed } from "deoptkit/harness";
import { parseRecord } from "../dist/index.mjs";
const inputs = makeVariedInputs(); // realistic, varied shapes — not homogeneous
observed("parse record", (i) => parseRecord(inputs[i % inputs.length]), {
iterations: 50_000
});Then profile_run { command: ["node", "bench/parse.bench.mjs"] } and window the findings with fromMark: "parse_record_start", toMark: "parse_record_end".
To check that a fix is also faster, time the two versions against each other in one process with interleaved(): every round runs each variant once in rotating order, and the medians over rounds are what to compare (back-to-back before/after runs on a busy machine drift by 30% or more).
import { interleaved } from "deoptkit/harness";
import { parseRecord as before } from "../dist-before/index.mjs";
import { parseRecord as after } from "../dist/index.mjs";
const { variants } = interleaved(
"parse record",
{
before: (i) => before(inputs[i % inputs.length]),
after: (i) => after(inputs[i % inputs.length])
},
{ rounds: 9, iterations: 20_000 }
);
console.log(variants.before.medianMs, variants.after.medianMs);Schema-driven inputs (Valibot + Valimock) are the easiest way to get realistically varied shapes — see docs/recipes.md.
Vitest bench mode
Requires Vitest 5, where bench is a test-context fixture.
// vite.config.ts
import { defineConfig } from "vitest/config";
import { deoptKit } from "deoptkit/vitest";
export default defineConfig({
test: { ...deoptKit({ outDir: ".deopt" }) }
});// src/__benchmarks__/parse.bench.ts
import { test } from "vitest";
import { benchObserved } from "deoptkit/bench";
import { parseRecord } from "../parse";
test("parse record", async ({ bench }) => {
await benchObserved(bench, "parse record", () => parseRecord(next())).run();
});Run vitest bench, then load_manifest { path: ".deopt/manifest.json" } to load every worker's log as a session. (Coverage must be off — the preset refuses to run otherwise, since instrumentation changes optimization behavior.) To observe the shipped code at exact source positions, without Vite's module runner in between, use deoptKit({ nativeEsm: true }); see the recipe for its requirements.
Bound the logs with a fixed count. Vitest runs each case for a time budget (one second by default), so under V8 logging a fast case runs hundreds of thousands of times, and with every log category on, one run can write gigabytes. iterations runs each case a fixed number of times instead, with no time budget and no warm-up. deoptkit's benchmark provider applies the count, and you select it in your own benchmark config:
export default defineConfig({
test: {
...deoptKit({ outDir: ".deopt", iterations: 2_000 }),
benchmark: { provider: "deoptkit/vitest/provider" }
}
});A bench that asks for fewer calls keeps its count (.run({ iterations: 5 }) for a slow case stays 5). The count also makes the findings repeat from run to run, which is what deoptkit ci --manifest needs. If iterations is set without the provider, the run stops at once with a message, since every case would otherwise still run for the time budget.
Full recipes, including SSR apps (Next/Astro via deoptkit/serve) and builds, live in docs/recipes.md.
Inline editor diagnostics (LSP)
deoptkit ci (and the vitest workflow) write .deopt/findings.json. Point the language server at it to get deopt squiggles in your editor:
deoptkit lsp --findings .deopt/findings.jsonThe server watches the file and republishes on every change, so vitest bench --watch rewrites .deopt/findings.json on every run and the squiggles update live. It publishes diagnostics at source-mapped original positions with the explanation and suggested fix in the hover; severity maps from the finding score (error ≥ 60, warning ≥ 25, info below), and findings older than the file they annotate downgrade to info with a re-run hint. Findings the committed baseline already accepts (baselined in the interchange, set by deoptkit ci) and findings in code the profiler never sampled (heat of 0) publish as hints, which editors keep out of the Problems panel, so the panel lists only what is new and actually ran.
Editor wiring. The server speaks standard LSP (initialize + push-model publishDiagnostics), so any editor with a generic LSP client works today:
- Neovim:
vim.lsp.start({ name = "deoptkit", cmd = { "npx", "deoptkit", "lsp" }, root_dir = vim.fn.getcwd() }) - Helix / Zed: register a language server with command
npx deoptkit lspand attach it tojavascript/typescript.
VSCode: install the deoptkit extension (marketplace id saeris.deoptkit-vscode). It starts deoptkit lsp for the first workspace folder and shows findings as squiggles and Problems entries. It runs the workspace's installed deoptkit (node_modules/deoptkit/dist/main.mjs, the same version that wrote findings.json) and falls back to the server bundled with the extension when there is none. Three settings: deoptkit.findingsPath (default .deopt/findings.json, relative to the workspace folder), deoptkit.serverCommand (a command such as ["npx", "deoptkit", "lsp"] that replaces both server choices) and deoptkit.enable (default true), which the deoptkit: Toggle Enable/Disable command flips for the workspace when the squiggles are in the way. VSCode + Claude Code users also get the same findings through the MCP server — the agent reads them via get_findings and reports them in chat, which needs no editor setup at all.
CI regression gating
deoptkit ci bench/parse.bench.mjs # first run writes .deopt/baselines/…, passes
deoptkit ci bench/parse.bench.mjs # exit 1 only on NEW structural findings
deoptkit ci --update bench/parse.bench.mjs # accept current findings as the baselineBaselines contain identity-only structural findings (no line numbers, severities, or timing), so they survive unrelated edits and noisy CI runners — this is the payoff of deoptkit's structural-vs-timing split. On GitHub Actions, new findings also emit ::warning annotations at their source-mapped positions. Commit .deopt/baselines/ to make "no new megamorphic sites" a guarded invariant.
Identities use paths relative to the directory deoptkit ci runs in, so run it from the same project directory locally and in CI and the committed baseline matches everywhere.
Findings that sit on a threshold can flicker between runs; build the baseline with deoptkit ci --accumulate over a few runs so that only genuinely new sites fail the gate.
To gate Vitest benches instead of scripts, run them under the preset with a fixed iterations count, then gate the manifest the run wrote:
vitest bench --run # writes .deopt/manifest.json
deoptkit ci --manifest .deopt/manifest.json # all of the run's logs, one baselineThe manifest lists only the logs that run wrote, and every one must analyze, or the gate exits 2. A gate over part of a run would pass for the wrong reason. Run the same bench files each time: a run over fewer files reports the rest as findings that no longer occur.
This repository gates itself the same way: bench/parse.bench.mjs parses a fixture log under observation and .github/workflows/ci.yml runs deoptkit ci over it against the committed baseline.
What the signals mean
| Signal | What it tells you |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Megamorphic IC | A property/call site saw 5+ object shapes; V8 fell back to generic hash lookups there |
| Deopt loop | TurboFan repeatedly optimized and discarded the same code — type instability after warm-up |
| Map churn | One constructor/site produces many hidden classes for conceptually one type (conditional or variably-ordered property init) — the root cause behind polymorphic reads elsewhere |
| Dictionary mode | Objects at a store site left fast-properties mode (keyed stores past V8's 12-field soft limit, or delete); every access on them is a hash lookup |
| Profile ticks | Where CPU time actually goes; severity ranking weights every other signal by this |
IC findings also say what reached the site: receiver: "string" means polymorphism over V8's own string representations (not fixable by property order), "array" means arrays differing only by elements kind (keep each array's element type uniform), and both rank below object-shape polymorphism.
Contributing
The project uses Vite+ as a unified toolchain (Oxlint + Oxfmt + tsdown + Vitest) and Bumpy for versioning and release. Run vp install, vp check --fix, and vp test from the repository root, and vp pack and node fixtures/generate.mjs from this package directory. See the repository README for the repository layout, and docs/SPEC.md and docs/BENCHMARKING.md for the design and roadmap.
Acknowledgements
deoptkit derives its log-parsing approach from deoptexplorer-vscode (MIT), which in turn incorporates code from V8's tick processor (BSD-3-Clause) and thlorenz/deoptigate (MIT). Portions of src/parser/csv.ts are derived from V8's tools/csvparser.mjs. See THIRD_PARTY_NOTICES.md.
License
Released under the MIT license © Drake Costa.
