---
title: "How vetd works"
description: "How vetd decides which files a patch can affect: the tokenizer, export surfaces, hashing, propagation, the tsgo integration, self-verification, benchmarks and limits, with references."
url: https://vetd.dev/how-it-works
updated: 2026-11-12
---

# How vetd works

vetd checks whether a patch breaks a project's types before the patch is written to disk. It keeps a TypeScript 7 compiler (`tsgo`) running, fingerprints each file's exported types, and sends the compiler only the files a patch can affect.

1. [Overview](#1-overview)
2. [Tokenizer](#2-tokenizer)
3. [Export surfaces](#3-export-surfaces)
4. [Hashing](#4-hashing)
5. [Propagation](#5-propagation)
6. [Semantic cutoff](#6-semantic-cutoff)
7. [Compiler integration](#7-compiler-integration)
8. [The Go patch](#8-the-go-patch)
9. [Forks and worktrees](#9-forks-and-worktrees)
10. [Self-verification](#10-self-verification)
11. [Benchmarks](#11-benchmarks)
12. [Limits](#12-limits)
13. [References](#13-references)

---

## 1. Overview

Most edits don't change what other files can see. A fix inside a function with a declared return type is invisible to its importers. vetd re-checks a file only if something it imports changed its exported types. Build systems call this early cutoff [1]; vetd applies it per exported name.

A check runs these steps and stops at the first one that has the answer.

| # | What vetd does | Stops here if |
|---|---|---|
| 1 | Applies the patch in memory | The patch changes nothing |
| 2 | Re-reads the changed files | |
| 3 | Compares tokens, ignoring comments, whitespace and JSX text | Only those changed, in a file with no errors |
| 4 | Compares export fingerprints | None changed |
| 5 | Finds importers of the changed names | There are none |
| 6 | Compares printed types, when 12+ files would be rechecked | The types are equal |
| 7 | Type-checks the remaining files with tsgo | |
| 8 | Compares with the errors on disk | A new error is found |
| 9 | Runs the Next.js rules on the changed files | |

The patch lives in an in-memory overlay that the compiler reads first. Real writes go through `writeAtomic` and `removeInside` in `src/core/vfs.ts`. When an edit can't be proven isolated, vetd checks more files. A check that can't run returns `NOT_CHECKED` and `ok: false`.

## 2. Tokenizer

vetd doesn't build a syntax tree. `src/core/lexer.ts` makes one pass over the source and stores tokens in typed arrays (kind, start, end, punctuation code), about 11 bytes per token.

The hard part is context. A `/` divides after an expression and starts a regex elsewhere; `<` can be a comparison, a generic or JSX [4]. The lexer keeps a mode stack (code, template, JSX tag, JSX children, JSX expression) and looks at the previous token to decide. A file whose brackets don't balance is marked `broken` and its exports count as unknown.

## 3. Export surfaces

A file's surface is every token that can affect its exported types. `src/core/scan-surface.ts` replaces the rest with markers before hashing:

1. Bodies of functions with a declared return type: `function f(): number { ... }` becomes `{T}`.
2. Initializers of annotated top-level variables: `const x: Foo = ...` becomes `=T`.
3. Bodies of un-annotated functions that only return JSX or `null`, checked by `returnShape`. This covers React components, whose inferred type doesn't depend on the JSX.

Each top-level statement is hashed. An export's hash covers the statements its name reaches, every non-declaration statement (such as `Button.displayName = "Button"`, which changes `Button`'s type), and the imports they use.

```ts
const closure = (startNames, selfStmt) => {
  const visit = (idx) => {
    if (seenS.has(idx)) return;
    seenS.add(idx);
    for (const ref of stmts[idx].refs) queue.push(ref);
  };
  stmts.forEach((st, idx) => { if (!st.isDecl) visit(idx); });
  while (queue.length) {
    const nm = queue.pop();
    // import binding: record a dependency. local declaration: visit its statement.
  }
  return { stmts: [...seenS].sort((a, b) => a - b), deps: [...deps].sort() };
};
```

## 4. Hashing

`src/core/hash.ts` uses cyrb53 [5], a 53-bit non-cryptographic string hash. The risk is an edit that changes a surface but keeps its hash, so an importer gets skipped. For an ideal hash that's about 1 in 10^16 per changed statement. A bug in the lexer or surface rules is far more likely than a collision.

## 5. Propagation

`computeImpact` in `src/engine/impact.ts` turns changed files into files to re-check:

1. Every changed file is re-checked.
2. If its surface hash is unchanged, stop.
3. Otherwise the exports whose hash moved, or that were added or removed, are the changed names.
4. An importer is re-checked only if it uses a changed name. Namespace imports, `export *`, dynamic imports and deleted modules count as using every name.
5. The importer's own exports that reference a changed name become its changed names. Repeat from step 4.

Each re-check records why:

```
edited: body only (no export surface changed → early cutoff)
edited: surface of formatPrice, Price changed
uses formatPrice of lib/money.ts, whose types changed
edited: surface tokens of formatPrice changed but tsgo proves the exported types identical → semantic cutoff
```

Import cycles terminate because a file is queued again only when its set of changed names grows, and that set can't exceed its export count:

```ts
const merged = new Set([...(prev ?? []), ...next]);
if (!prev || merged.size > prev.size) { changed.set(imp, merged); queue.push(imp); }
```

## 6. Semantic cutoff

Renaming a parameter or writing `Array<T>` instead of `T[]` changes tokens but not types. When a change would re-check 12 or more files, `src/engine/semantic.ts` prints each changed export's type in the old and new program, strips parameter names, and compares. Equal types count as unchanged. It probes up to 32 exports, most-imported first.

```ts
if (!fallback && !diskEqual && impact.recheck.size >= semanticMinFanout()) {
```

The threshold is set by `VETD_SEMANTIC_MIN`. The case that motivated it is excalidraw, where one export reaches `App.tsx` and from there about 370 files.

## 7. Compiler integration

vetd never loads the full `tsconfig`. It serves tsgo an in-memory tsconfig that extends the real one and lists only:

- the working set (files a check needed, up to 4,000)
- global-scope roots (scripts, `declare global`, `declare module`)
- files the patch creates
- a stub that re-imports packages which add globals, so `process` and similar names still resolve

A smaller program means faster snapshot updates and less memory.

`tsc --incremental` tracks a signature per file [6][7]. vetd tracks one per export and adds the semantic probe. It needs `@typescript/native-preview` 7.0.0-dev.20260707 or newer [2][3].

## 8. The Go patch

`go/build.sh` fetches the exact `typescript-go` commit the project has installed, adds one function to the checker, and builds it. `go/vetd_hooks.go`:

```go
// VetdCheckNodes type-checks only the given top-level statements of sf, plus
// the deferred work they register (function bodies, …), and returns the
// diagnostics this checker holds for sf. Per-declaration re-checking: after a
// body-only edit, only the edited declaration needs the checker.
func (c *Checker) VetdCheckNodes(ctx context.Context, sf *ast.SourceFile, nodes []*ast.Node) []*ast.Diagnostic {
	c.ctx = ctx
	c.checkSourceElements(nodes)
	c.checkDeferredNodes(sf)
	c.ctx = nil
	return c.diagnostics.GetDiagnosticsForFile(sf.FileName())
}
```

vetd rejects a binary built from different sources or a different tsgo commit. `src/engine/declcheck.ts` uses it only when re-checking single statements is exact: the statement skeletons, imports and statement count are unchanged, both versions parse, and there are no `@ts-` directives or `noUnused*` options. Diagnostics outside the edited ranges are reused.

The Go backend is optional and ships prebuilt for macOS and Linux. Without a matching binary, vetd uses stock `tsgo --api`.

## 9. Forks and worktrees

A fork is a copy of the overlay, graph and cached results (`src/engine/fork.ts`). Forks share the same tsgo processes; switching costs one snapshot update.

There's one daemon per repo, app folder and dependency set. Another worktree with the same dependencies attaches as a fork. Idle tsgo stops after 5 minutes, the daemon after 10, and forks after 2 hours. A crashed child is respawned; if it dies twice in one check, the result is `ok: false`.

## 10. Self-verification

| Check | What it does | What it misses |
|---|---|---|
| `verify()` | Rechecks the files vetd picked in a second tsgo | Files vetd should have picked |
| Oracle | Diffs a full `tsgo --noEmit` of base and fork [8] | Nothing; costs two full checks |
| Replay | Runs both on real commits, one at a time | Whatever those two miss |

`engine/oracle.ts` materializes the fork in a temp directory with hardlinks and symlinked `node_modules`.

## 11. Benchmarks

From `site/benchmarks.json`, 2026-11-11, on an M-series Mac (10 CPUs, 24 GB), Node 24.20, tsgo 7.0.0-dev.20260707.2. Each repo replays its last 30 commits, with an oracle run every 5th.

| Repo | Full check | vetd median | Result |
|---|---|---|---|
| taxonomy | 246 ms | 65 ms | 3.8x faster |
| commerce | 1265 ms | 432 ms | 2.9x faster |
| saas-starter | 511 ms | 275 ms | 1.9x faster |
| excalidraw | 302 ms | 1395 ms | 4.6x slower |
| hono | 65 ms | | did not run |
| zod | | | did not run |

Hono failed during replay and zod during install.

| Repo | Files | Incremental commits | Oracle mismatches |
|---|---|---|---|
| commerce | 65 | 13 of 30 | 112 |
| saas-starter | 41 | 11 of 30 | 39 |
| excalidraw | 695 | 19 of 30 | 1 |
| taxonomy | 125 | 23 of 30 | 37 |

Every repo has TypeScript 7 config errors in its baseline (removed options like `baseUrl` and `moduleResolution=node10`), so the full-check times are probably too low. Excalidraw's 302 ms for 695 files is the least believable. The other commits fell back to a full check because vetd couldn't prove the edit isolated, for example after a `package.json` change.

## 12. Limits

vetd can pass a patch the compiler would reject.

- **Oracle, 189 mismatches.** All in the safe direction: vetd reported errors the full compiler didn't, mostly TS2307 (cannot find module). Cause not yet known; it may be the config errors above.
- **`verify()`, 22 mismatches.** On excalidraw and taxonomy, vetd missed errors, mostly TS2305 and TS2459. Because `verify()` shares vetd's graph, the true miss count is unknown.

Treat `ok: true` as an early answer for agents, not a replacement for a full check and build in CI (`docs/compatibility.md`).

- macOS and Linux only, Node 22.18+.
- Full rules for the Next.js App Router. Pages Router gets routes and links only.
- The diagnostics cache is off by default after an earlier bug gave a false pass.
- Six oracle runs per repo is a small sample.

## 13. References

1. A. Mokhov, N. Mitchell, S. Peyton Jones. *Build Systems à la Carte.* ICFP 2018. <https://www.microsoft.com/en-us/research/publication/build-systems-la-carte/>
2. A. Hejlsberg. *A 10x Faster TypeScript.* TypeScript blog, March 2025. <https://devblogs.microsoft.com/typescript/typescript-native-port/>
3. Microsoft. *microsoft/typescript-go.* <https://github.com/microsoft/typescript-go>
4. Ecma International. *ECMAScript Language Specification, Lexical Grammar.* <https://tc39.es/ecma262/multipage/ecmascript-language-lexical-grammar.html>
5. bryc. *cyrb53*, 2018. <https://github.com/bryc/code/blob/master/jshash/experimental/cyrb53.js>
6. S. Nandi. microsoft/TypeScript PR #48616, 2022. <https://github.com/microsoft/TypeScript/pull/48616>
7. S. Nandi. microsoft/TypeScript PR #35711, 2020. <https://github.com/microsoft/TypeScript/pull/35711>
8. W. M. McKeeman. *Differential Testing for Software.* Digital Technical Journal 10(1), 1998. <https://www.hpl.hp.com/hpjournal/dtj/vol10num1/vol10num1art9.pdf>
