Kiira
ChevronDown
Github

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.

TriangleAlert

Experimental

The rule and plugin API can change between minor releases while it settles. See Experimental API.

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 edits fix is refused, the fix is dropped from every file it edits.
  • It refuses paths outside the project, inside .git or node_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 \n in newText is 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:

  • create runs once per file for a document or program rule, and once per run for a project rule. It can be async.
  • Ranges are zero-based, like the rest of Kiira's diagnostics. The CLI adds one when it prints them.
  • report takes range and message. An optional severity ("error", "warning", or "info") can lower the configured level for that one report. It cannot raise it: a rule set to warn that reports "error" still gives a warning. An optional fix is applied by --fix (see Return a fix). A project rule's report also takes the file, and a range that defaults to the start of the file.
  • ctx.snippets lists the fences in the file. Every fence identifier Kiira recognizes (ts, typescript, mjs, and so on) is normalized to ts, tsx, js, or jsx on snippet.lang.
  • A program rule also gets ctx.program, ctx.checker, and ctx.toMarkdownRange(virtualFile, start, end), which maps an offset span in a snippet's generated file back to a Markdown range. It returns undefined for generated code.
  • A rule that throws does not stop the check. Kiira reports an error diagnostic with the rule id as its code and a message that names the rule, the file, and the error. The other rules still run. A failed project rule 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:

  • raw is the text between the two --- lines, with the file's own line endings.
  • range runs from the start of the opening --- to the end of the closing ---.
  • bodyStart is 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

TriangleAlert

Experimental

The TypeScript hook can change between minor releases while it settles.

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: compilerOptions are shallow-merged and paths merge per key, with later hooks winning. The "*" key is different: each hook adds its targets after the earlier ones. replaceTsconfig is true if any hook says so. A diagnostic is dropped if any filterDiagnostic returns false.
  • A file's options are layered in this order: the tsconfig (or Kiira's defaults), the unused-symbols level, matching overrides, the hook's compilerOptions, then the hook's paths.
  • Files whose final options differ are checked in separate programs, so one document's hook never changes how another is checked.
  • A filterDiagnostic that throws fails the run with an error that names the plugin or preset and the file.
  • filterDiagnostic runs on TypeScript diagnostics only, before unused-symbols, relative-imports, and document rules see them. Its snippet is 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.