Chapter 11 · Engineering culture
Turning review comments into lint rules
How T3 Code turns a correction made twice in review into a custom oxlint rule with its own test, and which correction from your own commits is ready to become one.
When you work alone, you correct the same mistake in your own code more than once and remember it. In a team, a reviewer leaves the same comment on the tenth pull request. In T3 Code, where most pull requests come from agents, nobody remembers anything between sessions. A comment in review fixes one pull request. A lint rule fixes every future one.
T3 Code keeps about thirty custom rules in one folder, oxlint-plugin-t3code/rules. This chapter reads four of them, the pull requests that added them, and the problem each one replaced. Then it compares the lint setups in your own repos and picks a correction from your history that is ready to become a rule.
Where the rules live and how lint finds them
TL;DR
The rules are a small local plugin. The repo’s one config file loads it and then turns each rule on only for the folders where it makes sense. Lint runs before every commit, so an agent sees the error before CI does.
T3 Code lints with oxlint, a fast linter written in Rust, through vite-plus. Oxlint can load plugins written in JavaScript or TypeScript. T3’s plugin is one file, oxlint-plugin-t3code/index.ts, that imports each rule and registers it under a name:
import { definePlugin } from "@oxlint/plugins";
import noEmDashCopy from "./rules/no-em-dash-copy.ts";
// ...
export default definePlugin({
meta: {
name: "t3code",
},
rules: {
"no-em-dash-copy": noEmDashCopy,
// ...
},
});The plugin’s name becomes the prefix, so the rule is t3code/no-em-dash-copy everywhere else. The root vite.config.ts loads it by path, next to a published plugin for shadcn components:
plugins: ["eslint", "oxc", "react", "unicorn", "typescript"],
jsPlugins: ["./oxlint-plugin-t3code/index.ts", "@shadcn/lint"],Loading a rule does not turn it on. The rules block switches on the ones that apply everywhere, and the overrides list switches on the rest for specific folders. Browser-test rules apply only to *.browser.test.tsx files. The rule against useMemo and useCallback applies to web, and mobile gets an option that still allows those hooks. Every override that turns a rule off carries a comment saying why, such as “The t3 npm launcher is published as plain, dependency-free Node.”
Lint also runs before every commit. The staged block in vite.config.ts runs format and then lint on staged files, with the comment “CI lint failures cost a full round trip.” PR #26167 added the lint step after the maintainers found lint was the most common CI failure, 13 of 40 sampled runs.
The em dash rule in thirty lines
TL;DR
A rule is a function that receives syntax tree nodes and reports the bad ones. The em dash rule checks three kinds of text node. Its test writes a tiny file, runs the real linter on it, and checks for the error.
The review comment it replaced
PR #23799, opened by Theo, starts with “Em dashes kept showing up in our UI copy.” Someone had caught one in the Scheduled tasks settings, in the sentence that describes running a prompt on an interval. The PR calls that “not acceptable.” Catching them by eye in review had not worked. The PR rewrote every em dash in product copy across 46 files and added a rule so the fix stays fixed.
How the rule is written
Here is the whole rule, no-em-dash-copy.ts, minus its imports:
const EM_DASH = "\u2014";
const MESSAGE = "No em dashes in product copy. Use a period, comma, colon, or parentheses instead.";
// Comments are not product copy, so only string and JSX text nodes are checked.
export default defineRule({
// ...
create(context) {
const check = (node: ESTree.Node, text: string | null | undefined) => {
if (text?.includes(EM_DASH)) context.report({ node, message: MESSAGE });
};
return {
Literal(node) {
if (typeof node.value === "string") check(node, node.value);
},
TemplateElement(node) {
check(node, node.value.cooked);
},
JSXText(node) {
check(node, node.value);
},
};
},
});The linter parses each file into a syntax tree, a nested object where every string, function call, and JSX tag is a node with a type. create returns an object whose keys are node types. The linter walks the tree and calls Literal for every literal, TemplateElement for every piece of a template string, and JSXText for text between JSX tags. The rule never sees comments, because comments are not one of those node types. That is how “comments may keep em dashes” turns into code with no extra branch.
The message tells the reader what to do instead. The other rules in this chapter do the same. The person reading the error is often an agent, and an error that names the fix leaves it nothing to guess.
Scope lives in the config, not the rule. The override in vite.config.ts applies it to apps/*/src/** and packages/*/src/**, skips tests and generated code, and skips one marketing file with the comment “Real user quotes stay verbatim.” The PR also names a known gap. Oxlint does not parse Astro template markup, so marketing .astro pages were fixed by hand and stay unchecked.
How the rule is tested
The test, no-em-dash-copy.test.ts, lists source snippets that must pass and snippets that must fail:
const rule = createOxlintRuleHarness("t3code/no-em-dash-copy", {
filename: "apps/web/src/components/Example.tsx",
});
describe("t3code/no-em-dash-copy", () => {
rule.valid(
"allows em dashes in comments and regex literals",
// ...
);
rule.valid("allows hyphens and en dashes", `const label = "Refresh failed - retry 1–2";`);
rule.invalid("reports a string literal", `const label = "Refresh failed — retry";`);
rule.invalid("reports a template literal", "const label = `${name} — ${kind}`;");
rule.invalid("reports JSX text", `const node = <p>Run a prompt — on an interval</p>;`);
});The valid cases matter as much as the invalid ones. They pin down what the rule must leave alone: comments, regular expressions, hyphens, and en dashes. Without them, a later “improvement” that also flags en dashes would pass its own tests.
The shared harness in test/utils.ts does not call the rule function directly. For each case it makes a temp folder, writes an .oxlintrc.json that loads the plugin and enables only this rule, writes the snippet to a file, and runs the real oxlint binary on it:
yield* fs.writeFileString(
configPath,
yield* encodeOxlintConfig({
jsPlugins: [{ name: "t3code", specifier: pluginPath }],
rules: { [ruleName]: ["error", ...(options.ruleOptions ?? [])] },
}),
);
yield* fs.makeDirectory(path.dirname(sourcePath), { recursive: true });
yield* fs.writeFileString(sourcePath, source);
// ...
const output = yield* spawnAndCollectOutput(
ChildProcess.make(process.execPath, [oxlintBin, "--config", configPath, sourcePath], {
cwd: repoRoot,
}),
);An invalid case passes only if oxlint exits with an error and the output names the rule. This is slower than calling the rule function directly, but it checks three things a direct call would skip. The plugin loads, the rule is registered under the right name, and the real parser produces the node types the rule expects. The filename option matters too, since some rules look at the file path.
The tooltip rule stays narrow on purpose
TL;DR
Agents kept using the browser’s plain title tooltip instead of the app’s styled one. The rule that stopped them is narrow on purpose, so it never fires on correct code. The price is that it misses some real cases, and you can find one in the code today.
PR #7209, by Utkarsh Patil, explains the problem in its Why section. “AI models keep using the native tooltip instead of the app’s styled tooltip component. This has regressed a few times.” The title attribute shows a plain browser tooltip that cannot be styled. The PR migrated 33 flagged uses to the app’s Tooltip component and added a rule.
A rule that flags every title would be wrong. <iframe title> names embedded content for screen readers. <svg><title> is a child element. <SettingsRow title="Word wrap"> is a prop on a custom component that means something else. So the rule in no-native-title-tooltip.ts exits early in three ways before it reports:
const INTRINSIC_ELEMENT_PATTERN = /^[a-z]/u;
// ...
const TITLE_IS_AN_ACCESSIBLE_NAME = new Set(["embed", "frame", "iframe", "math", "object"]);
// ...
JSXOpeningElement(node) {
if (node.name.type !== "JSXIdentifier") return;
if (!INTRINSIC_ELEMENT_PATTERN.test(node.name.name)) return;
if (TITLE_IS_AN_ACCESSIBLE_NAME.has(node.name.name)) return;
for (const attribute of node.attributes) {
if (attribute.type !== "JSXAttribute") continue;
if (attribute.name.type !== "JSXIdentifier") continue;
if (attribute.name.name !== "title") continue;
context.report({
node: attribute,
message:
"Do not use the native title attribute as a tooltip. Use Tooltip + TooltipTrigger + TooltipPopup from components/ui/tooltip.",
});
}
},Lowercase tag names are built-in HTML elements in JSX, and capitalized names are components. The regex /^[a-z]/u uses that convention to skip every custom component. The test file has six valid cases, one for each thing the rule must ignore, before any invalid case.
The PR is open about the cost. It migrated 33 flagged tooltips “plus a few on pass-through components it can’t see.” A component like Button passes title through to a real <button>, but the rule only sees the capital B. You can find one at the commit this book reads. ProjectPinButton.tsx sets title on a Button, and lint passes. The PR gives the reason for that trade. The rule is “scope-tight so it catches real native tooltips without false-positiving on accessibility uses or component props.” A rule with false alarms teaches people, and agents, to suppress it.
An escape hatch that costs a sentence
TL;DR
Some patterns are usually wrong but sometimes right. T3 Code flags every React effect, and lets one stay only if the line above it says what it syncs with. A second rule rejects any lint suppression that does not give a reason.
The em dash rule has no exceptions. Many review comments are softer, like “this effect is probably unnecessary.” T3 Code handles those with two rules that work as a pair.
require-suppression-reason
In October 2026, Erik Thorelli was moving review rules into the docs in PR #16286. One rule said every lint suppression must say why. In a review thread on that PR, Erik wrote “also tried writing a lint rule, and about 11 violations would need to be fixed,” and opened PR #16294 for it separately. That PR states the problem plainly. “The Effect Service Conventions review check asks every new or broadened suppression to say why. Most already do… but nothing enforces it, so a few don’t.”
The rule reads every disable directive the way oxlint itself parses them:
for (const directive of sourceCode.getDisableDirectives().directives) {
if (directive.type === "enable" || directive.justification?.trim()) continue;
if (explainedAbove(directive.node)) continue;
context.report({
loc: directive.node.loc,
message:
"Say why: end the directive with `-- reason` or put a comment on the line above.",
});
}A directive passes if it ends in -- reason or has a comment on the line above. The same rule checks @ts-expect-error and @ts-ignore.
no-unreasoned-state-sync
React’s useEffect is often used to copy one piece of state into another, which causes extra renders and bugs. For a while T3 Code capped effects per file in a budget file. PR #26161, by Julius Marminge, explains why that failed. “The budget never went down when effects were removed (1017 allowed against 503 left), so in 196 files an agent could add an effect back without a word.”
The replacement reports every useEffect, useLayoutEffect, and useInsertionEffect outside tests. An effect that should stay keeps a suppression with its reason, such as -- subscribes to BackHandler for the focused screen's lifetime. The PR wrote a reason for each of the 447 effects that had none, and two independent reviews corrected 11 of them. Now each kept effect documents itself, and the reason sits where the next reader looks.
A day later, PR #26181 rewrote only the error message. The first version “asked one vague question and then handed over the suppression comment, so an agent could paste it with a generic reason without reconsidering the effect.” The new message walks through the alternatives first:
// Agents read this message and act on it, so it walks the alternatives before it shows the way out.
const MESSAGE =
"Stop and reconsider before keeping this effect. " +
"Is it computable from props or state? Compute it during render; the compiler memoizes it. " +
"Does it respond to a user action? Do it in that handler. " +
// ...
"Moving the same logic into a ref or store to dodge this rule is not a fix. " +
"If the effect does that, name what it keeps in step on the line above: " +
"`// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- <what it syncs with and why>`.";This is a lint message written as a prompt. Its reader is an agent that will do what the message says, so the message orders the steps the way a reviewer would.
Which corrections become lint, and which stay with a reviewer
Not every review rule can be a lint rule. T3 Code keeps judgment calls in review bots. .macroscope/check-run-agents/effect-service-conventions.md is a prompt for a model that reviews each pull request against the server’s conventions. Rules like “a transport handler calls one service method” need a reader that understands intent, so they stay there. Rules a syntax tree can decide, like “this suppression has no reason,” move into the plugin. The suppression rule followed that path. The review check still asks for an explanation next to every new directive, and lint now enforces the part a syntax tree can see.
AGENTS.md closes the loop. Its testing section ends with “Lint enforces this:” and names five test rules, and its comments rule names t3code/no-low-value-comments. An agent reads the prose, and if it ignores the prose, the lint error repeats it.
How your lint setups compare
TL;DR
Your repos run stock lint presets with almost no custom rules. Vibe-idx already uses the same linter and config file as T3 Code, so adding a local rule there is a small step. For a solo project, a handful of rules for your own repeated mistakes is plenty.
fud. eslint.config.mjs uses the recommended presets for TypeScript and Svelte, turns one rule off, and configures unused variables. It is clean and short. Its ignore list still contains .next/** and .convex/** from before the move to SvelteKit, which is harmless but shows that config drifts too. There are no project rules, so every fud convention lives in AGENTS.md and CONTEXT.md as prose.
monosodiums. eslint.config.js is nine lines, the base JavaScript preset and browser globals. For a Three.js site with no framework, that is enough. Most of its risk is visual, and lint cannot see that.
vibe-idx. vite.config.ts uses vite-plus and oxlint, the same tools as T3 Code. It turns on type-aware linting, adds the React and Vitest plugins only where they apply through overrides, and runs vp check --fix on staged files. That is the same shape as T3’s config, without a jsPlugins line.
What T3 Code does that you don’t is turn its own corrections into rules. It has about thirty rules because most of its pull requests come from agents that start every session knowing nothing. You do not need thirty. You need one for each correction you have made more than once.
Your history has a clear candidate. Vibe-idx’s AGENTS.md line 5 says “Avoid UI separators such as the dot or slash or any other separator.” Your shared home file, agents/home/AGENTS.md, says it again for every project. The commits show why it was written. Commit 61b6180 in vibe-idx removed 30 lines containing · and its message says “drop the dot separator from historical broker names.” Four days later, commit bcb148f says “Also adds the no-separators rule to AGENTS.md.”
So you made the correction by hand, then wrote it down as prose. The prose has not been enough elsewhere. At the commits this book reads, fud has 20 · separators in its source files, such as BigSlip.svelte, and antre has 32. Some may be deliberate, but nothing asks. That is the same pattern as T3’s em dashes. An instruction is repeated in several files, broken anyway, and fixed by eye. It is ready to be a rule.
Try it
TL;DR
Write a no-separator rule for vibe-idx by copying the shape of T3’s em dash rule, test it by running the real linter on small snippets, and then decide what fud and antre should do with their separators.
-
Add a local oxlint plugin to vibe-idx. Create
lint-plugin/index.tsthat callsdefinePluginwithmeta: { name: "vibe" }and registers one rule,no-separator-copy. Model the rule on T3’sno-em-dash-copy. VisitLiteral,TemplateElement, andJSXText, and report when the text contains·or a lone/between words. Make the message say what to do instead, for example “No separators in UI copy. Put the two values in separate elements and let the layout separate them.” In vibe-idx’s rootvite.config.ts, addjsPlugins: ["./lint-plugin/index.ts"]to thelintblock and turn the rule on in an override forapps/web/src/**. You are done whenvp lintpasses on vibe-idx, and adding<span>{a} · {b}</span>to any component makes it fail with your message. -
Test it the way T3 Code does. Add
lint-plugin/no-separator-copy.test.tswith at least threevalidcases (a comment containing·, a URL path like"/t/:ticker", and a fraction like"1/2") and threeinvalidcases (a string, a template literal, and JSX text). Copy the idea of T3’s harness. Write a temp config that loads only your plugin, write the snippet to a file, run theoxlintbinary, and check the exit code and output. You are done when breaking the rule on purpose, for example by deleting theJSXTextvisitor, makes the JSX case fail. -
Decide for fud and antre. Run
git grep -n ' · ' -- '*.svelte' '*.ts'in fud and antre. For each hit, either rewrite the markup so the two values sit in separate elements, or keep it and write down why in one line. Fud and antre lint with ESLint andeslint-plugin-svelte, which parses Svelte templates, so the same check can become a small ESLint rule there. You are done when every remaining separator in those repos has a reason you would defend in review.