Rules
Every built-in Kiira rule with its scope, default level, and what it reports, plus how to write a minimal rule and plugin with kiira-core/plugin.
What a rule is
Everything Kiira reports besides TypeScript's own errors comes from a rule: fence metadata warnings, the tsx language-tag suggestion, group= suggestions, and so on. Each rule has an id, a scope, and a default level. Set the level in rules, or per run with --rule.
Every diagnostic a rule reports carries the rule's id as its code, so the reporters and the VS Code extension show it next to the message.
A rule's scope decides what it can see and when it runs:
| Scope | Runs | Sees |
| --- | --- | --- |
| document | Once per Markdown file, after type-checking. | The text, the parsed Markdown tree, the fences, and the TypeScript diagnostics already produced for the file. |
| program | Once per Markdown file that has checked fences, right after its document rules. | Everything a document rule sees, plus the TypeScript Program and type checker for the file's snippets. |
| project | Once per run. | The list of files in the run, the project (cwd, package.json, workspace packages, isTracked), and read-only file access. It can report on any file, not only Markdown. |
Program rules only run for documents that have at least one type-checked fence, and only on the classic engine, because only it builds a ts.Program in-process. On the native engine they are skipped, and Kiira reports one info diagnostic with the code program-rules-skipped that names them. Set engine: "classic" to run them.
Built-in rules
Built-in rule ids have no prefix. They run first, in this order, followed by plugin rules in the order the plugins are listed.
| Rule | Scope | Default | What it reports |
| --- | --- | --- | --- |
| parse-error | document | error | A Markdown or MDX file that fails to parse. No fences from that file are checked. |
| fence-meta | document | warn | An invalid validate= or package= value in a fence's info string. |
| language-tag | document | warn | A ts fence that contains JSX. Suggests tsx, with a fix for --fix. The fence is still checked as tsx when the rule is off. |
| group | document | warn | Fences that continue an earlier one. Suggests a group= tag, with a fix. |
| jsx-framework | document | warn | JSX that fails because of the wrong runtime types, in a file whose path names a framework. Suggests a jsxImportSource override, with a fix. |
| unused-symbols | document | off | Turns on TypeScript's unused local and parameter checks. At warn, those diagnostics are warnings. |
| relative-imports | document | off | Keeps Cannot find module errors for relative imports, which are dropped by default. At warn, they are warnings. |
| broken-link | document | off | A relative link, image, or definition whose target file does not exist. Option anchors (boolean, default false) also checks #heading anchors. |
| max-lines | document | off | A file with more lines than the required option max (a positive integer). |
| deprecated-import | program | off | An imported binding whose declaration has a @deprecated JSDoc tag. Option packages (string array) limits it to those packages and their subpaths. |
unused-symbols and relative-imports report nothing themselves. Their level controls the TypeScript diagnostics they own, which keep their TS codes. The checkUnusedSymbols and checkRelativeImports options still work as aliases.
Optional rules
The last three rules are off until you turn them on. Nothing in them changes Kiira's output unless you do.
import { defineConfig } from "kiira-core"export default defineConfig({ rules: { "broken-link": ["error", { anchors: true }], "max-lines": ["warn", { max: 400 }], "deprecated-import": ["warn", { packages: ["@acme/ui"] }], },})broken-link checks Markdown links, images, and link definitions ([ref]: ./file.md), in .md and .mdx files. It skips anything with a scheme (https:, mailto:), links that start with / (such as /latest/cli/fix, which are site routes and not files), and #anchors unless anchors is on. The ?query and #hash are removed before the file is looked up. A target resolves from the document's folder. A target without a file extension, such as ./guide, must exist as written, or as guide.md, guide.mdx, guide/index.md, or guide/index.mdx. The file name must match in case, also on Windows and macOS, so a link that works there does not break on Linux. With anchors, a file.md#heading or #heading link must match an anchor in the target file. Headings use GitHub's anchors: lowercase, punctuation removed, spaces turned into hyphens, and repeated headings suffixed -1, -2. A custom heading id (## Install {#setup}) and the id or name of an HTML or JSX element (<a id="setup"></a>) also count. Links written as MDX JSX elements are not checked.
max-lines has no default for max, so "max-lines": "error" without options fails when the config is resolved. It counts \n and \r\n line endings. A trailing newline ends the last line and does not count as an extra line. It reports once, on the first line past the limit.
deprecated-import follows import { a, b as c }, default imports, and re-exports back to the declaration, and reports the local name with the tag's text ('oldFn' is deprecated: use newFn). An overloaded function is reported only when every overload is deprecated, as TypeScript does. Namespace imports are not checked. It works in js and jsx fences too, and runs only on the classic engine like any other program rule.
Presets
| Preset | Rules |
| --- | --- |
| recommended | broken-link at error and deprecated-import at warn. |
export default defineConfig({ presets: ["recommended"], rules: { "broken-link": ["error", { anchors: true }] },})Write a rule
This section is a short summary. The plugin guide covers writing a plugin step by step, and the reference covers rule settings, presets, and the order Kiira merges them in.
Import defineRule and definePlugin from kiira-core/plugin. The entry has no runtime dependencies, so it works from a plain .mjs config as well as TypeScript.
This rule flags a word in the prose of every document:
import { defineConfig } from "kiira-core"import { definePlugin, defineRule } from "kiira-core/plugin"const noWord = defineRule({ meta: { scope: "document", defaultSeverity: "warn", options: { default: { word: "TODO" }, validate: (options) => typeof (options as { word?: unknown })?.word === "string" ? undefined : "`word` must be a string", }, }, create(ctx) { ctx.text.split("\n").forEach((line, index) => { const character = line.indexOf(ctx.options.word) if (character === -1) return const start = { line: index, character } const end = { line: index, character: character + ctx.options.word.length } ctx.report({ range: { start, end }, message: `Remove "${ctx.options.word}" before publishing.` }) }) },})export default defineConfig({ plugins: [definePlugin({ name: "docs", rules: { "no-word": noWord } })], rules: { "docs/no-word": ["error", { word: "FIXME" }] },})The rule is registered as docs/no-word, and its diagnostics use that as their code.
Return a fix
A report can carry an edits fix, which kiira check --fix applies. Each edit names a file (relative to the project root, with / separators), a zero-based range to replace, and the newText. A zero-width range inserts.
This report call replaces the flagged word with DONE:
ctx.report({ range: { start, end }, message: `Remove "${ctx.options.word}" before publishing.`, fix: { kind: "edits", edits: [{ file: ctx.file, range: { start, end }, newText: "DONE" }], },})An edits fix can also change another file, such as one the rule read with ctx.fs.readText. --fix is careful with the files it writes:
- It only edits a file the check read: a Markdown document in the run, or a file a rule read with
ctx.fs.readText. An edit to any other file is refused. - It re-reads the file first and refuses it if the text changed since the check, so a fix never lands on stale ranges. It also refuses a file that is not valid UTF-8 or is read-only.
- A fix is all or nothing. If one file of an
editsfix is refused, the fix is dropped from every file it edits. - It refuses paths outside the project, inside
.gitornode_modules, or that reach either through a symlink. - It refuses every edit in a file when two of them overlap, and prints which fixes conflict. Other files are still fixed. Identical edits count once, and inserts at the same point apply in report order.
- It keeps a file's line endings: in a file that uses CRLF, a
\ninnewTextis written as\r\n. A line's characters stop before its\r\n.
Each refusal is printed with its reason. Run kiira check --fix --dry-run to see the diff first.
A few things to know:
createruns once per file for adocumentorprogramrule, and once per run for aprojectrule. It can beasync.- Ranges are zero-based, like the rest of Kiira's diagnostics. The CLI adds one when it prints them.
reporttakesrangeandmessage. An optionalseverity("error","warning", or"info") can lower the configured level for that one report. It cannot raise it: a rule set towarnthat reports"error"still gives a warning. An optionalfixis applied by--fix(see Return a fix). A project rule'sreportalso takes thefile, and arangethat defaults to the start of the file.ctx.snippetslists the fences in the file. Every fence identifier Kiira recognizes (ts,typescript,mjs, and so on) is normalized tots,tsx,js, orjsxonsnippet.lang.- A
programrule also getsctx.program,ctx.checker, andctx.toMarkdownRange(virtualFile, start, end), which maps an offset span in a snippet's generated file back to a Markdown range. It returnsundefinedfor generated code. - A rule that throws does not stop the check. Kiira reports an
errordiagnostic with the rule id as itscodeand a message that names the rule, the file, and the error. The other rules still run. A failedprojectrule is reported on the first file of the run.
Frontmatter
A document that starts with a --- line and has a later line of exactly --- has frontmatter. Rules get it as ctx.frontmatter, or undefined when the document has none:
rawis the text between the two---lines, with the file's own line endings.rangeruns from the start of the opening---to the end of the closing---.bodyStartis the first line after the block.
Kiira does not parse YAML, so a plugin brings its own parser. The block is left out of ctx.mdast, and the line, column, and offset of each node after it are the same as in the file. ctx.text is still the whole file. A byte order mark before the opening --- is skipped. If a code fence opens between the two --- lines, the block is not frontmatter, so the fence is still checked.
This rule requires a name: line:
import { defineRule } from "kiira-core/plugin"export const requireName = defineRule({ meta: { scope: "document", defaultSeverity: "error" }, create(ctx) { const { frontmatter } = ctx if (!frontmatter) return if (!/name:/m.test(frontmatter.raw)) { ctx.report({ range: frontmatter.range, message: "Frontmatter needs a `name:` line." }) } },})Options that fail validate, an unknown rule id, a rule with an unknown scope or defaultSeverity, and a plugin without a name all fail when the config is resolved, not when the rule runs.
TypeScript hook
A plugin or preset can set its own TypeScript options for each Markdown file. Add a typescript function next to rules. It gets the file path and a context with the document text, its snippets, the project, and read-only fs, and returns what to change for that file, or undefined to change nothing.
| Result | Effect |
| --- | --- |
| compilerOptions | tsconfig-style options, in the same string form as overrides. An invalid option throws an error that names the plugin or preset and the file. paths and baseUrl also throw an error. Use the paths result instead. |
| replaceTsconfig | When true, start from Kiira's default options instead of the project tsconfig. allowJs and checkJs stay on unless your compilerOptions turn them off. Workspace and external-package resolution still apply. |
| paths | Path mappings merged on top of the file's paths, including the ones from your workspace. Values are relative to cwd, also when your tsconfig sets a baseUrl. Targets for "*" go after the existing "*" targets. |
| filterDiagnostic | Called for each TypeScript diagnostic of the file. Return false to drop it. |
This plugin checks docs against Kiira's defaults instead of the project tsconfig, resolves @docs/* imports to a folder of examples, and drops "cannot find name" errors for a global the docs assume:
import { defineConfig } from "kiira-core"import { definePlugin } from "kiira-core/plugin"const docs = definePlugin({ name: "docs", typescript(file) { if (!file.startsWith("docs/")) return undefined return { replaceTsconfig: true, compilerOptions: { noImplicitAny: false }, paths: { "@docs/*": ["./examples/*"] }, filterDiagnostic: (diagnostic) => !diagnostic.message.includes("'appConfig'"), } },})export default defineConfig({ plugins: [docs] })A few things to know:
- Hooks run once per document. Presets' hooks run first, in preset order, then plugins' hooks in plugin order.
- Results merge:
compilerOptionsare shallow-merged andpathsmerge per key, with later hooks winning. The"*"key is different: each hook adds its targets after the earlier ones.replaceTsconfigistrueif any hook says so. A diagnostic is dropped if anyfilterDiagnosticreturnsfalse. - A file's options are layered in this order: the tsconfig (or Kiira's defaults), the
unused-symbolslevel, matchingoverrides, the hook'scompilerOptions, then the hook'spaths. - Files whose final options differ are checked in separate programs, so one document's hook never changes how another is checked.
- A
filterDiagnosticthat throws fails the run with an error that names the plugin or preset and the file. filterDiagnosticruns on TypeScript diagnostics only, beforeunused-symbols,relative-imports, and document rules see them. Itssnippetis the fence that contains the diagnostic, or the file's first fence when the diagnostic is in generated code.- Editor quick fixes in VS Code use the same options.