Extending Doctor
A Doctor Extension adds Rule Packs to a Doctor Run. A Rule Pack groups Rules, Presets, and the Diagnostic Codes those Rules emit. Use extensions for project-specific policies, or ship them with a library so its users get the library's diagnostics automatically.
Authoring APIs come from vite-doctor/extension. Test helpers come from vite-doctor/testkit.
Write a Doctor Extension
This file defines one Rule, its Diagnostic Code, a Rule Pack, and the extension that registers the pack:
import {
createRule,
defineDoctorDiagnostics,
defineDoctorExtension,
defineRulePack,
} from "vite-doctor/extension";
export const acmeDiagnostics = defineDoctorDiagnostics(
[{ code: "ACME0001", ruleId: "acme/no-legacy-client" }],
{ docsBase: "https://acme.dev/doctor" },
);
export const noLegacyClient = createRule({
meta: {
id: "acme/no-legacy-client",
title: "Use the current Acme client",
category: "correctness",
severity: "warn",
prefilter: { imports: ["acme/legacy"] },
},
create(ctx) {
return {
ImportDeclaration(node) {
if (node.source.value !== "acme/legacy") return;
ctx.report(
acmeDiagnostics.diagnostics.ACME0001({
why: "`acme/legacy` is removed in Acme 3.",
fix: "Import `createClient` from `acme` instead.",
}),
{ range: ctx.range(node) },
);
},
};
},
});
export const acmeRulePack = defineRulePack({
name: "acme",
version: "1.0.0",
rules: [noLegacyClient],
diagnostics: acmeDiagnostics,
presets: { recommended: ["acme/no-legacy-client"] },
});
export default defineDoctorExtension({ name: "acme", rulePacks: [acmeRulePack] });
ctx.report takes a diagnostic created by a code handle. Every diagnostic needs why and fix. The second argument is optional: ruleId, severity, and category default to the Rule's meta, and you can add range, file, evidence, confidence, or a structured fix edit plan.
Visit script nodes
create(ctx) returns visitors keyed by ESTree node type, the same shape ESLint and oxlint use. Doctor walks each file's script once and calls a visitor only for nodes of its type. Add :exit to run after the node's children. This Rule reports every createClient() call after the first one in a module, with ACME0002 registered next to ACME0001:
import { createRule, type ScriptAstNodeOf } from "vite-doctor/extension";
export const singleClient = createRule({
meta: {
id: "acme/single-client",
title: "Create one Acme client per module",
category: "correctness",
severity: "warn",
prefilter: { calls: ["createClient"] },
},
create(ctx) {
const clients: ScriptAstNodeOf<"CallExpression">[] = [];
return {
CallExpression(node) {
if (node.callee.type === "Identifier" && node.callee.name === "createClient") {
clients.push(node);
}
},
"Program:exit"() {
for (const node of clients.slice(1)) {
ctx.report(
acmeDiagnostics.diagnostics.ACME0002({
why: "Each client opens its own connection pool.",
fix: "Reuse the first client in this module.",
}),
{ range: ctx.range(node) },
);
}
},
};
},
});
Each visitor's node parameter is typed for its key: CallExpression(node) receives a CallExpression. Script ASTs come from oxc-parser, so the node types match its ESTree output, including TypeScript and JSX nodes. To type a helper, use ScriptAstNodeOf<"CallExpression"> or the ScriptAstNode union from vite-doctor/extension. Vue SFC Rules can also use the SFC hook and template visitors.
Doctor links script parents before any visitor runs. Visitors must treat the AST as read-only.
Visit template nodes
For Vue SFCs, return a template object with visitors keyed by node kind: root, element, attribute, directive, text, interpolation, and comment. Doctor walks the <template> AST that @vue/compiler-sfc builds when it parses the SFC, so template visitors add no parse of their own. This Rule reports <AcmeImage> elements without alt text and :src bindings to plain http: URLs, with ACME0003 and ACME0004 registered for acme/image:
export const acmeImage = createRule({
meta: {
id: "acme/image",
title: "Use accessible, secure Acme images",
category: "a11y",
severity: "warn",
},
create(ctx) {
return {
template: {
element(node) {
if (node.tag !== "AcmeImage") return;
if (
ctx.helpers.hasVueAttribute(node, "alt") ||
ctx.helpers.hasVueDirective(node, "bind", "alt")
)
return;
ctx.report(
acmeDiagnostics.diagnostics.ACME0003({
why: "Screen readers announce an image without alt text by its file name.",
fix: 'Add alt text, or alt="" for a decorative image.',
}),
{ range: ctx.range(node) },
);
},
directive(node, element) {
if (element.tag !== "AcmeImage" || node.name !== "bind" || node.arg?.content !== "src")
return;
const expression = node.exp && ctx.helpers.parseTemplateExpression(node.exp);
if (expression?.type !== "Literal" || !String(expression.value).startsWith("http:"))
return;
ctx.report(
acmeDiagnostics.diagnostics.ACME0004({
why: "An http: image on an https: page is mixed content that browsers upgrade or block.",
fix: "Serve the image over https:.",
}),
{ range: ctx.range(expression) },
);
},
},
};
},
});
Nodes are the compiler-dom nodes Vue's compiler produces, typed as TemplateElementNode, TemplateDirectiveNode, and so on in vite-doctor/extension:
- Each visitor receives the node and its parent.
attributeanddirectivereceive the element they belong to; other kinds receive their parent element or the template root. - An element's attributes and directives are visited in source order before its children.
"element:exit"and the other:exitkeys run after a node's props and children, so a Rule can track ancestors with a counter or stack. element.tagis the tag as written, such asbutton,NuxtLink, orrouter-view.element.propsholds static attributes (type: 6) and directives (type: 7).- Directive shorthands are normalized:
:href,@click, and#defaulthavenamebind,on, andslot, and a static argument inarg.content.rawNamekeeps the attribute name as written. - A static attribute value is
attribute.value?.content. Directive values and interpolations are strings inexp.content.ctx.helpers.parseTemplateExpression(exp)parses one with oxc on first use, shares the result with every Rule, and returnsnullfor a wholev-for(parseforParseResult.source),v-slotparameters, andv-onhandlers with several statements. - Template nodes and parsed expressions carry SFC offsets, so
ctx.range(node)points at the source.ctx.file.templateAstis the root, for Rules that need the whole template increate(ctx).
Template visitors do not run on <template src> or on templates with a non-HTML lang, such as Pug.
Skip files that cannot match
meta.prefilter skips a Rule on files that cannot produce its Diagnostics. Doctor does not call create(ctx) for those files. A Rule runs on a file when any listed signal is present:
| Field | Matches |
|---|---|
calls | Call expressions by callee name, such as useFetch or Math.random. new expressions do not count. |
imports | Static import declarations by exact source, such as acme/legacy. |
names | Names the file text could spell, including through escape sequences. Use it for aliases and reads. |
List every signal your Rule reports from. If a Rule also reports from create(ctx), SFC, or template visitors, or follows aliases such as const load = createClient, a calls prefilter can hide real Diagnostics; use names or omit the prefilter.
Rule Packs without activation run their recommended Preset whenever the extension is registered. Set activation: { packages: ["acme"] } to run only when the project depends on acme, activation: { frameworks: ["vue", "nuxt"] } to run only for Vue or Nuxt packages, or activation: false to require an explicit extends: ["auto", "acme/recommended"].
Diagnostic Codes
A Diagnostic Code is a package-owned uppercase prefix followed by four digits, such as ACME0001. Pick a prefix that belongs to your package and never reuse a code.
- Built-in prefixes are reserved:
DOC,NITRO,NUXT,PINIA,PKG,SHAD,TS,VITE, andVUE. Using them fails withDOC0028. A code that does not match the format fails withDOC0027. - Two Rule Packs in the same Doctor Run cannot declare the same code (
DOC0012). docsBaseproduceshttps://acme.dev/doctor/ACME0001. Pass a function to build another URL shape, or setdocson one entry to override or opt out withfalse. Without either, the code has no docs URL. Doctor does not link third-party codes to its own Diagnostic Reference.
Attach the registry as the Rule Pack's diagnostics so vite-doctor rules and vite-doctor explain ACME0001 list the codes and their docs URLs.
Add a project Rule
Keep project-only Rules in your repository and register the extension where you run Doctor.
With the CLI, load executable config explicitly:
import { defineDoctorConfig } from "vite-doctor/config";
import acme from "./doctor/extension";
export default defineDoctorConfig({
extensions: [acme],
});
pnpm vite-doctor . --config doctor.config.ts
pnpm vite-doctor explain ACME0001 --config doctor.config.ts
With the Vite plugin, pass the extension in vite.config.ts:
import { doctor } from "vite-doctor";
import { defineConfig } from "vite";
import acme from "./doctor/extension";
export default defineConfig({
plugins: [doctor({ extensions: [acme] })],
});
In a Nuxt app, register it from a local module the same way a library does. See Ship Rules with a Nuxt module.
Ship Rules with a Vite plugin
A Vite plugin can expose Doctor Extensions as api.doctor. The doctor() plugin reads api.doctor.extensions from every plugin in the resolved Vite config, so users who install your plugin get your Rules with no extra setup:
import type { Plugin } from "vite";
import type { DoctorPluginApi } from "vite-doctor/extension";
export function acme(): Plugin {
return {
name: "acme",
api: {
doctor: {
extensions: [() => import("./doctor.js")],
} satisfies DoctorPluginApi,
},
};
}
import { acme } from "acme/vite";
import { doctor } from "vite-doctor";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [acme(), doctor()],
});
An entry can be a Doctor Extension, or a loader that returns one or a module with a default export. Use a loader so your plugin imports Doctor code only during a Doctor Run. Then vite-doctor can stay an optional peer dependency:
{
"exports": {
"./doctor": "./dist/doctor.mjs"
},
"peerDependencies": { "vite-doctor": "*" },
"peerDependenciesMeta": { "vite-doctor": { "optional": true } }
}
The type import is erased at build time. If your package already depends on vite-doctor at runtime, defineDoctorPluginApi({ extensions }) from vite-doctor/extension gives the same typing.
Ship Rules with a Nuxt module
A Nuxt module registers extension entry modules through the doctor:extendExtensions hook. Push a path, file: URL, or package specifier. The entry's default export must be a Doctor Extension:
import { createResolver, defineNuxtModule } from "@nuxt/kit";
import type {} from "vite-doctor/nuxt";
export default defineNuxtModule({
meta: { name: "acme" },
setup(_options, nuxt) {
const { resolve } = createResolver(import.meta.url);
nuxt.hook("doctor:extendExtensions", (entries) => {
entries.push(resolve("./doctor"));
});
},
});
The import type {} from "vite-doctor/nuxt" line loads the hook types and is erased at build time. When vite-doctor/nuxt is not installed, the hook never runs.
Users add both modules and run the Nuxt Doctor Command:
export default defineNuxtConfig({
modules: ["acme", "vite-doctor/nuxt"],
});
pnpm nuxt prepare
pnpm nuxt doctor
The Nuxt 4 Bridge resolves each entry to an absolute file and records it in .nuxt/doctor.manifest.json during nuxt dev, nuxt build, and nuxt prepare. nuxt doctor loads recorded entries because it is a Nuxt host command, and running it is the same trust decision as nuxt dev. The standalone CLI does not run host-registered code unless you pass --host-extensions. Use that flag in CI, where Nuxt's host command can swallow Doctor's exit code:
pnpm vite-doctor . --host-extensions --max-warnings 0
Doctor loads entries with Node's native import(). Publish built JavaScript. For a local module in your app, keep the entry outside modules/, because Nuxt registers every file there as a module. A TypeScript entry works when Node can strip its types and its relative imports include file extensions. If an entry fails to load or its default export is not a Doctor Extension, the run stops with DOC0029. Rebuild the package and run nuxt prepare again.
A package that ships both a Vite plugin and a Nuxt module can point both contracts at the same ./doctor entry.
Test a Rule
vite-doctor/testkit writes fixture files to a temporary project, runs Doctor with only the Rules you pass, and returns the Doctor Run result:
import { expect, test } from "vitest";
import { runProjectFixture, runRuleFixture } from "vite-doctor/testkit";
import acme, { noLegacyClient } from "./extension";
test("reports legacy client imports", async () => {
const result = await runRuleFixture({
rule: noLegacyClient,
framework: "vite",
files: { "src/client.ts": 'import { createClient } from "acme/legacy";\n' },
});
expect(result.diagnostics.map((diagnostic) => diagnostic.code)).toEqual(["ACME0001"]);
});
test("runs the published extension", async () => {
const result = await runProjectFixture({
framework: "vite",
extensions: [acme],
files: { "src/client.ts": 'import { createClient } from "acme";\n' },
});
expect(result.diagnostics).toEqual([]);
});
framework defaults to vue and accepts vite, vue, nitro, or nuxt. Pass dependencies to add entries to the generated package.json. Use config and run for Doctor config and Doctor Run options such as extends. Fixture projects are temporary, so the rule cache stays in memory unless you pass run: { cache: true }.
Share a fixture project
runRuleFixture and runProjectFixture write a new project and detect its Project Inventory on every call. Most Rule tests change one source file, so createProjectFixture writes the project and detects its inventory once, then reuses both for every run:
import { afterAll, expect, test } from "vitest";
import { createProjectFixture } from "vite-doctor/testkit";
import { noLegacyClient } from "./extension";
const project = createProjectFixture({ framework: "nuxt", files: { "app/.gitkeep": "" } });
afterAll(() => project.dispose());
test.each(["acme/legacy", "acme/legacy/client"])("reports %s", async (entry) => {
const result = await project.run({
rule: noLegacyClient,
files: { "app/pages/index.vue": `<script setup>import "${entry}"</script>` },
});
expect(result.diagnostics.map((diagnostic) => diagnostic.code)).toEqual(["ACME0001"]);
});
createProjectFixture takes framework, dependencies, and files. Its files stay on disk for every run. run takes rule or rules, extensions, config, and run like runProjectFixture, adds its own files, runs Doctor on the whole project, and deletes its files before the next run starts. Runs on one fixture project never overlap.
The Project Inventory comes from the fixture project's own files, so run files cannot change it. Put files that shape it in createProjectFixture: dependencies, nuxt.config.ts, tsconfig.json, Nuxt server/ routes, or the Nuxt app/ directory, which app/.gitkeep creates without adding a source file. When a test needs its own inventory, use runRuleFixture or a separate fixture project. A run file that would replace a fixture project file throws. Call dispose() in afterAll to delete the fixture project.
How surfaces find extensions
| Surface | Registered extensions |
|---|---|
doctor() Vite plugin | extensions option, then api.doctor.extensions from other Vite plugins |
nuxt doctor | doctor:extendExtensions entries recorded by vite-doctor/nuxt, plus --config extensions |
vite-doctor CLI | --config extensions, plus recorded Nuxt entries with --host-extensions |
vite-doctor/testkit | rules and extensions passed to the fixture |
The CLI cannot see extensions from Vite plugins in vite.config.ts, because it does not load host config. Run those through the Vite plugin, or register the same extension in doctor.config.ts and pass --config.
The extension name is its identity. If the same name is registered more than once, Doctor keeps the first registration. You can register an extension explicitly while a plugin also contributes it, and its Rules still run once.
Rule inputs
A Rule reads its file through ctx.file and Project Inventory through ctx.project. Any other file, directory listing, or path check goes through ctx.fs:
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
create(ctx) {
const pkg = ctx.fs.readJson("package.json");
if (!isRecord(pkg) || !isRecord(pkg.dependencies) || !pkg.dependencies.acme) return;
const config = ctx.fs.readText("acme.config.ts");
// ...
}
ctx.fs offers readText, readJson, exists, stat, readDir, readDirRecursive, realpath, and glob. Relative paths resolve from the project root, and missing files return undefined instead of throwing. readJson returns unknown, so validate the value before using it. Doctor records every read as an input of the Rule, so do not import node:fs or read process.env in Rule code; built-in Rule Packs enforce this with a lint rule.
ctx.cache is memory shared by all Rules for one Doctor Run. A value remembered with ctx.cache.set carries the ctx.fs reads made since the matching ctx.cache.get missed, so another Rule that reuses it depends on the same inputs. Derive cached values only from the key, ctx.fs, and ctx.project, never from the current file.
Doctor caches each Rule's Diagnostics per file between runs and replays them while the file, the Project Inventory, the Rule config, and every input the Rule read are unchanged. The cache key includes the Rule Pack name and version, the Rule's meta (prefilter included), and the source of its create function, so bump the Rule Pack version when a Rule's helpers change. Set meta.cacheScope: "none" for a Rule that reads anything ctx.fs cannot see, and meta.determinism: "env-dependent" or "runtime-dependent" for a Rule whose result depends on the environment or a network call. Doctor runs those Rules every time. A Rule that changes Project Inventory, such as adding evidence gaps, is not cached either.
Report scope
A file Rule reports Diagnostics into the file it analyzes: the reported file and every related location must be ctx.file.path. That is the default meta.reportScope: "file", and Doctor enforces it. A file Rule that reports anywhere else stops the Doctor Run with DOC0030, naming the Rule, the analyzed file, and the other file. Evidence file entries are not report locations and are not checked.
A file Rule that does report into other files, for example on the module an import resolves to, declares it:
createRule({
meta: {
id: "acme/imported-legacy",
title: "Legacy module",
category: "imports",
severity: "warn",
reportScope: "project",
},
create(ctx) {
// ...
ctx.report(diagnostics.ACME0002(), {
file: target,
related: [{ file: ctx.file.path, message: "Imported here" }],
});
},
});
Manifest and workspace Rules (execution: "manifest" or "workspace") report on the project and are always project-scoped; reportScope does not apply to them and they are never checked.
The scope decides what a --changed run analyzes. Diagnostics of a file-scoped Rule can only land in the file it analyzed, so --changed runs file-scoped Rules on changed files only, and does not even parse unchanged files unless something needs them. Project-scoped file Rules, manifest Rules, workspace Rules, and --analyses still see the whole project. Either way the report equals a full run filtered to the changed lines, so declare "project" whenever a Rule can report outside its file; a Rule that is wrongly left file-scoped fails with DOC0030 on any run that reaches the offending report.
File Rule execution contract
File Rules run one file at a time. For each file, Doctor skips Rules whose
meta.prefilter cannot match, awaits every remaining Rule's create(ctx) in
enabled-rule order, then awaits every SFC hook in that order. It walks the
script once in depth-first order. On entering a node it calls each Rule's
visitor for that node type, in Rule order; after the node's children it calls
the :exit visitors the same way. The template walk follows with the same
enter and exit order, using each Rule's template visitors. All callbacks for
one file finish before the next file's create(). Diagnostics appear in Rule
order, then file order.
Custom Rule Packs that coordinate through shared module state or ctx.cache
must not assume an earlier Rule has finished its callbacks when their create()
runs. Use caches for reusable facts, not to sequence Rules.
The catch-all ScriptNode hook was removed. Move its body into visitors for
the node types it checked, for example ScriptNode(node) { if (node.type !== "CallExpression") return; ... } becomes CallExpression(node) { ... }.
The catch-all TemplateNode hook was removed too, along with the
vue-eslint-parser node shapes it received. node.type === "VElement" becomes
template: { element(node) {} }, rawName becomes tag,
startTag.attributes becomes props, a directive's key.name.name and
key.argument.name become name and arg.content, a static
value.value becomes value.content, value.expression becomes
ctx.helpers.parseTemplateExpression(exp), and parent chains become
element and "element:exit" visitors.
Treat SFC, script and template ASTs as read-only. Script parent links are complete before script callbacks run, and both traversals are snapshotted before callbacks: replacing or removing children does not remove their queued visits, and inserted children are not visited. This also applies to the single-visitor runner. AST mutations are unsupported; no mutation behavior is guaranteed across Rules. Keep analysis state outside the AST.