Reading T3 Code

Chapter 3 · How the repo is organized

The toolchain

How T3 Code installs, lints, typechecks, finds dead code, runs only the checks a change needs, and starts isolated dev servers per worktree, all behind the vp command.

15 min read

A toolchain is the set of programs that turn your source into something you can check, run, and ship. On a solo project you can get by with npm run dev and a linter you run when you remember. T3 Code has about 27,000 TypeScript files, several hundred contributors over its history, and many agents working at once. At that size, the toolchain decides how fast anyone learns that a change is broken.

This chapter walks through what runs when a T3 Code maintainer types vp, how lint and dead-code checks are wired, how caches keep typecheck and tests fast, the one script that runs CI’s fast checks on just your change, and how every worktree gets its own dev server without stepping on the others.

One command for the whole toolchain

TL;DR

T3 Code runs almost everything through one program, vp, and configures all of it in one file at the root. Install, tasks, lint, format, tests, and builds share that file, so there is one place to look when a check behaves oddly.

vite-plus ships a command called vp. In T3 Code it replaces several separate tools. The lockfile shows what vite-plus@1.4.0 pulls in: oxlint for lint, oxfmt for formatting, vitest for tests, and Vite itself. The workspace catalog even points the vite package name at vite-plus’s own core build:

t3code/pnpm-workspace.yaml:83-84
  vite: npm:@voidzero-dev/vite-plus-core@1.4.0
  vite-plus: 1.4.0

So every package that asks for Vite gets the same copy. Here is how the everyday commands map:

You type What it does
vp i Installs dependencies through pnpm
vp run <task> Runs a package.json script or a configured task, with an optional cache
vp lint Runs oxlint with the repo’s config and plugins
vp fmt Formats with oxfmt
vp test run <files> Runs Vitest
vp staged Runs checks on staged files, called from the Git pre-commit hook

All of these read one file, the root vite.config.ts. It has a run block for tasks and caching, a test block, a staged block, a fmt block, and a lint block. Packages that need their own test setup, such as apps/web, have their own config, and the root config hands each test file to the right one.

The move to this setup is recent. Julius Marminge’s PR #2899, in June 2026, migrated “the workspace from Bun/Turbo split tooling to Vite+ with pnpm”. It deleted turbo.json, three per-app turbo.jsonc files, and bun.lock.

The pre-commit hook

The committed file .vite-hooks/pre-commit contains one line, vp staged. The staged block in the config says what that runs:

t3code/vite.config.ts:166-172
  staged: {
    // In order, so lint reads the formatted file. CI lint failures cost a full round trip.
    "*": ["vp fmt --no-error-on-unmatched-pattern", "vp lint --no-error-on-unmatched-pattern"],
    // knip checks the whole repo, so it takes no file list. Any code or manifest change can leave
    // a file, export, or dependency unused.
    "{*.{js,jsx,ts,tsx,mjs,cjs,mts,cts},package.json,knip.jsonc}": () => "vp run knip:check",
  },

Each comment gives a reason. Format runs before lint so lint sees the final file. Knip gets no file list, because deleting one export can leave an unused file somewhere else.

Lint and dead-code checks

TL;DR

Lint runs oxlint plus the team’s own plugin of house rules, turned on folder by folder. A second tool, knip, finds code nobody uses. A small script teaches knip that schemas are allowed to sit unused, because they are part of the wire format.

How lint is wired

The root lint script is vp lint --report-unused-disable-directives. The config’s lint block loads built-in rule sets and two JavaScript plugins:

t3code/vite.config.ts:226-663
    plugins: ["eslint", "oxc", "react", "unicorn", "typescript"],
    jsPlugins: ["./oxlint-plugin-t3code/index.ts", "@shadcn/lint"],
// ...
    categories: {
      correctness: "warn",
      suspicious: "warn",
      perf: "warn",
    },
// ...
    options: {
      reportUnusedDisableDirectives: "error",
      typeAware: false,
      typeCheck: false,

The first entry in jsPlugins is a local workspace package, oxlint-plugin-t3code. Its index.ts calls definePlugin with the name t3code and registers 34 rules, each in its own file under rules/ with a test beside most of them. In the config, they appear as "t3code/<rule-name>": "error".

Rules are turned on by folder through overrides. For example, t3code/no-sparkles-icon applies only to the web, mobile, and desktop source folders. t3code/prefer-effect-fn is turned off in test files. A rule that only makes sense for one surface does not bother the others. What those rules check, and how a repeated review comment becomes one, is the subject of the chapter on lint rules.

reportUnusedDisableDirectives: "error" matters more than it looks. If you write // oxlint-disable-next-line above a line that no longer breaks the rule, lint fails. Suppressions cannot pile up after the code they excused is gone.

typeAware: false is also deliberate. The PR that moved to Vite+ said typed oxlint checks stay off “until the Oxlint tsgolint path can integrate with @effect/tsgo diagnostics”. Type checking runs as its own step instead.

knip finds what nobody uses

knip reads the whole workspace and reports unused files, unused dependencies, and unused exports. vp run knip:check runs two passes side by side. One checks files and dependencies for the whole repo. The other checks unused exports in the server, desktop, web, and every internal package.

T3 Code has a problem with plain knip. packages/contracts exports many schemas that describe wire messages before any code reads them. Knip would call those dead. knip.jsonc turns off the check for exported types. Schemas are values, not types, so that setting does not cover them.

The fix is a knip preprocessor, scripts/knip-schemas.ts. Knip finds its issues, then hands them to this function before it reports them. The function asks the TypeScript compiler whether each flagged export is an Effect schema:

t3code/scripts/knip-schemas.ts:89-95
        const type = checker.getTypeOfSymbolAtLocation(target, target.valueDeclaration ?? source);
        const marker = type.getProperty(schemaTypeId);
        if (!marker) return [];
        const markerType = checker.getTypeOfSymbolAtLocation(marker, source);
        return markerType.isStringLiteral() && markerType.value === schemaTypeId
          ? [symbol.name]
          : [];

Every Effect schema’s type carries a property named ~effect/Schema/Schema. If the export’s type has it, the export is a schema, and the preprocessor deletes that issue from knip’s report. A function that builds or decodes schemas has no such property, so knip still flags it when it is unused.

The comments in the file record the speed work. One shared document registry exists because “Without it, building 17 programs took about 13 s.” The worker count is capped at three because “Three workers measured fastest.” Julius added the preprocessor in PR #10171, titled “enforce runtime exports while allowing types and schemas”.

Typecheck and test caches

TL;DR

Every package typechecks on its own, and the task runner remembers which ones passed with unchanged inputs. Server tests go one step further, with one cached task per test file shared between machines, plus a spot check that reruns a few cached results for real.

Typecheck per package

Each package’s package.json has the same script, "typecheck": "effect-tsgo tsrs --noEmit". That runs tsrs, a TypeScript checker written in Rust, with Effect’s extra checks built in. Julius’s PR #31028 made this the only checker and removed the typescript package everywhere except the Astro site. It reported apps/server typechecking “in 14.6 s with tsrs, against 15.9 s with tsc-rs”.

The root script runs it across the workspace with a cache:

t3code/package.json:29-34
    "typecheck": "vp run -r --cache --concurrency-limit 4 typecheck",
// ...
    "check:changed": "node scripts/check-changed.ts",
// ...
    "lint": "vp lint --report-unused-disable-directives",
    "knip": "knip --preprocessor ./scripts/knip-schemas.ts",

-r runs the task in every package. --cache tells vp to record each package’s inputs and result. On the next run, a package whose inputs did not change is skipped and its earlier result is replayed. AGENTS.md sums it up as “Use vp run typecheck; it caches per package.”

A cache shared between machines

The root config can point that task cache at a server. infra/task-cache/src/remoteCache.ts builds the URL from .task-cache.json and a credentials file in your home folder. If either is missing, it returns nothing and the cache stays local. Its comment says “Missing or damaged optional setup must not prevent ordinary project commands.” The server is a Cloudflare Worker that the team deploys from infra/task-cache.

One cached task per test file

Server tests are slow enough that the team caches them one file at a time. scripts/test-cached.ts lists the test files and passes them to the config in an environment variable. Then scripts/lib/cached-tests.ts turns each group of files into its own vp task:

t3code/scripts/lib/cached-tests.ts:96-111
      const key = NodeCrypto.createHash("sha256").update(group.join("\0")).digest("hex");
      tasks[`test-cache:${key}`] = {
// ...
        cache: uncached.includes(group[0]!)
          ? false
          : {
              // Vitest reads and rewrites these caches while running otherwise unchanged tests.
              input: [
                { auto: true },
                "!**/node_modules/.vite/**",
                "!**/node_modules/.vitest-cache/**",
              ],
              output: [],
            },

{ auto: true } asks vp to work out which files the task read. If none of them changed, the result comes from the cache, possibly one that another machine wrote.

A cache that lies is worse than no cache. So test-cached.ts takes a --verify-hits count. After a passing run, it picks that many cache hits and runs them again with caching off:

t3code/scripts/test-cached.ts:115-122
  if (result.status === 0) {
    for (const task of verify) {
      console.log(`Verifying cached result without cache: ${task.task_name}`);
      const start = performance.now();
      const audit = NodeChildProcess.spawnSync(
        NodePath.join(root, "node_modules/.bin/vp"),
        ["run", "--no-cache", "--remote-cache", "off", task.task_name],
        { cwd: root, env, stdio: "inherit" },

If a rerun fails, the job fails. PR #24966 set this to one hit per shard, so a bad cache entry shows up as a red build instead of a hidden bug.

Run CI’s fast checks on just your change

TL;DR

One command works out what your branch changed, picks the packages that change can break, and runs format, lint, knip, typecheck, and nearby tests on just those, in parallel. Theo built it after finding that most failed CI runs were errors a local check would have caught.

Theo Browne opened PR #22767 with a number: “About 59% of 121 failed PR CI runs in a 12-hour sample failed on knip, lint, or typecheck errors that a local run would have caught.” Agents had no single command for CI’s checks, and “they rarely ran knip”. The fix is vp run check:changed, and AGENTS.md tells every contributor to run it before pushing.

scripts/check-changed.ts does five things in order:

  1. It finds the merge base with main and lists every changed and untracked file since then.
  2. It hashes the diff, the changed files’ bytes, and the install state into one key. If the key matches the last passing run, it prints that run’s summary and stops.
  3. It asks decide() from .github/scripts/ci-changes.cjs which packages are affected. That is the same function CI uses, so local and CI pick the same packages.
  4. It picks test files. It takes changed test files, plus any test file whose name starts with a changed file’s name, such as foo.test.ts for foo.ts.
  5. It runs the steps side by side and prints a summary, with the CI jobs it did not run listed at the end.

Step 4 has a reason recorded in the code:

t3code/scripts/check-changed.ts:324-335
  // Changed test files, and the test files beside a changed source file
  // (foo.ts -> foo.test.ts, foo.browser.test.tsx). Vitest's import-graph
  // `related` reaches most of a package: one leaf server file pulled in 252 of
  // 406 server test files and took 7 minutes. CI runs the rest.
  const tests = new Set(code.filter((file) => TEST.test(file)));
  for (const file of code) {
    const dir = path.dirname(file);
    const prefix = `${path.basename(file).replace(CODE, "")}.`;
    for (const entry of yield* fs.readDirectory(inRoot(dir))) {
      if (entry.startsWith(prefix) && TEST.test(entry)) tests.add(path.join(dir, entry));
    }
  }

This is a trade-off you can copy. The precise answer (“every test that imports this file”) took 7 minutes. The cheap answer (“the test file next to it”) takes seconds and catches most mistakes. CI still runs everything.

The steps share a budget sized to the machine:

t3code/scripts/check-changed.ts:46-49
// A typecheck or test step can take several cores and gigabytes of memory, so
// allow one step per two cores and per 6 GiB of available memory.
export const defaultConcurrency = (cores: number, availableBytes: number) =>
  Math.max(1, Math.min(Math.floor(cores / 2), Math.floor(availableBytes / (6 * GiB))));

The typecheck step itself passes --parallel instead of waiting on dependency order. A comment says “—parallel cut a contracts change from 13 s to 9 s”, because the checker writes nothing another package reads. A step that runs past 900 seconds is killed and reported as TIMEOUT, not as a failure, so a slow machine does not look like a broken change.

The script started at “about 110 lines” according to its PR. At the pinned commit it is 626 lines, and the extra lines handle cases found in use, such as the cache key and the timeout.

Isolated dev servers per worktree

TL;DR

When T3 Code makes a new worktree, a setup script installs dependencies in the background and leaves a marker file saying whether it finished. The dev runner then picks ports and a data folder from the worktree’s path, so ten agents can each run the app without colliding or touching real user data.

T3 Code can give each thread its own Git worktree. Several agents then work at once, each with its own branch and folder. That raises three problems: each worktree needs dependencies installed, each dev server needs its own ports, and none of them may touch the developer’s real database.

Setup runs once, in the background

The repo’s t3.json tells T3 Code what to run when it creates a worktree:

t3code/t3.json:11-16
    {
      "name": "Setup Worktree",
      "command": "node scripts/setup-worktree.ts",
      "icon": "configure",
      "runOnWorktreeCreate": true
    }

scripts/setup-worktree.ts runs vp i --frozen-lockfile and links the main checkout’s .env files into the worktree. Because it runs in the background, an agent might start while it is still installing. The script handles that with marker files:

t3code/scripts/setup-worktree.ts:10-20
// T3 runs setup in the background, so agents can start before it ends.
// `setup-running` holds this PID until setup ends with `setup-done` or
// `setup-failed`, so agents wait instead of racing it with their own `vp i`.
const markerDir = NodePath.join(worktree, ".t3");
const runningMarker = NodePath.join(markerDir, "setup-running");
const doneMarker = NodePath.join(markerDir, "setup-done");
const failedMarker = NodePath.join(markerDir, "setup-failed");
NodeFS.rmSync(doneMarker, { force: true });
NodeFS.rmSync(failedMarker, { force: true });
NodeFS.mkdirSync(markerDir, { recursive: true });
NodeFS.writeFileSync(runningMarker, `${process.pid}\n`);

AGENTS.md tells agents how to read them. While .t3/setup-running names a live PID, wait. If neither setup-done nor setup-failed exists, setup never ran, so run vp i yourself. The failure marker keeps the last 30 lines of install output, so an agent can see why it failed without rerunning it.

Ports and data come from the path

vp run dev calls scripts/dev-runner.ts. It hashes the worktree path into a port offset:

t3code/scripts/dev-runner.ts:260-264
  const worktreePath = config.worktreePath?.trim();
  if (worktreePath) {
    const offset = ((Hash.string(worktreePath) >>> 0) % MAX_HASH_OFFSET) + 1;
    return Effect.succeed({ offset, source: `worktree ${worktreePath}` });
  }

The same worktree gets the same ports every time it starts, and two worktrees almost never share them. If a port is taken anyway, the runner probes for the next free offset and prints the real ports in its [dev-runner] line.

The data folder follows the same idea:

t3code/scripts/dev-runner.ts:734-741
  const worktreeHome = yield* resolveWorktreeT3Home(yield* HostProcess.WorkingDirectory);
  // Trim before choosing: `--home-dir ""` is not a selection, and treating it
  // as one would skip the worktree default and land on the shared home —
  // exactly the outcome this precedence exists to prevent.
  const resolvedT3Home =
    (input.t3Home?.trim() || undefined) ??
    worktreeHome ??
    (hostEnvironment.T3CODE_HOME?.trim() || undefined);

An explicit --home-dir wins. Next comes the worktree’s own .t3 folder. The T3CODE_HOME environment variable comes last. That order is the point. A developer often has T3CODE_HOME set in their shell, pointing at their real data. If the variable won, every agent’s dev server would open the developer’s live database, which AGENTS.md lists as one of “the three ways to hurt yourself”. The safe choice is the default, and the risky one needs a flag.

How your projects compare

TL;DR

vibe-idx already uses vite-plus and even turns on type-aware lint, which T3 Code cannot yet. fud has a thorough but all-or-nothing verify script. monosodiums has a screenshot diff tool that is a real check of its own. None of them has a fast “just my change” command or a cache.

vibe-idx

vibe-idx’s vite.config.ts uses the same defineConfig from vite-plus with fmt, lint, and staged blocks. One setting goes further than T3 Code:

vibe-idx/vite.config.ts:13-16
    options: {
      typeAware: true,
      typeCheck: true,
    },

vibe-idx has no Effect diagnostics to protect, so it can let oxlint use type information, which T3 Code keeps off. CI runs pnpm exec vp check on each app folder.

Two gaps stand out. The root package.json has no scripts and no run tasks, so nothing uses vp run --cache. And the staged block exists, but the repo has no committed hook folder and no prepare script. That suggests the pre-commit check runs only on a machine where someone installed the hook by hand. vibe-idx also pins vite-plus at ^0.2.1, while T3 Code is on 1.4.0.

fud

fud uses separate tools: ESLint, Prettier, Vitest, svelte-check, Playwright through scripts in scripts/, and Wrangler. Its verify script chains seven checks:

fud/package.json:28
    "verify": "pnpm lint && pnpm typecheck && pnpm test && pnpm build && pnpm check:web && pnpm check:platform && pnpm check:worker"

This is thorough. check:platform checks built SSR and auth in local Workers, which is closer to production than anything in T3 Code’s fast path. But it is all or nothing. It runs one step at a time, it stops at the first failure, and it reruns everything after a one-line change. A typo in a test file costs a full build before you learn about it.

monosodiums

monosodiums lints with ESLint’s recommended rules and nothing else. Its strongest check is visual. npm run shots saves screenshots of nine fixed scenes, and scripts/compare.mjs diffs two runs pixel by pixel and writes a .diff.png for each changed scene. For a Three.js scene, that catches more than any linter would. T3 Code asks for before and after screenshots on UI pull requests, but contributors take them by hand. Your compare script automates that.

When the T3 Code approach is overkill

You do not need a remote test cache, a knip preprocessor, or hashed ports for one developer. You do need the habit behind check:changed. Find the checks that fail most often in CI, and give yourself one fast command that runs those on your change before you push.

Try it

TL;DR

Give fud a fast check that looks only at what changed, turn on the task cache in vibe-idx, and make monosodiums lint on every commit. Each one ends with a command you can run to see it work.

  1. Add a fast check to fud. In fud/package.json, add "check:changed": "eslint --cache $(git diff --name-only --diff-filter=d main -- '*.ts' '*.svelte') && vitest run --changed main". The first half lints only changed files. The second runs only tests that Vitest links to changed files. Change one line in domain/money.ts and run pnpm check:changed, then pnpm verify. Compare the two times. The fast one should finish in seconds, and it should still fail if you break a test in tests/.
  2. Turn on the task cache in vibe-idx. In the root vibe-idx/package.json, add "scripts": { "test": "vp run -r --cache test" }. Run pnpm test twice without changing anything. The second run should report cache hits and finish much faster. Then edit one file in apps/pipeline and run it again. Only the pipeline’s tests should rerun. If your vite-plus version rejects --cache, upgrade it toward the 1.4.0 that T3 Code pins first.
  3. Lint every commit in monosodiums. Create monosodiums/.githooks/pre-commit containing #!/bin/sh and npm run lint, make it executable, and add "prepare": "git config core.hooksPath .githooks" to package.json. Run npm install to set the hook path. Add an unused variable to src/main.js and try to commit it. The commit should stop with an ESLint error.

Text size

Theme