Skip to content

Writing Rules

Fred Souza edited this page Aug 3, 2026 · 2 revisions

A rule is a TypeScript program. That is true whether it checks Go, Python or TypeScript — see Home for why one embedded language rather than one per target.

Anatomy

import { defineRule } from 'lanekeep'

export default defineRule({
  id: 'local/no-fmt-println',
  language: 'go',
  severity: 'error',

  card: {
    message: 'fmt.Println in library code',
    remediation: 'use log/slog, so the output has a level and a destination',
    examples: {
      bad: 'fmt.Println("saved", count)',
      good: 'slog.Info("saved", "count", count)',
    },
  },

  gates: { fileContains: ['fmt'] },

  query: `
    (call_expression
      function: (selector_expression
        operand: (identifier) @pkg
        field: (field_identifier) @fn)) @call
  `,

  check(ctx, m) {
    if (ctx.text(m.pkg) !== 'fmt') return
    if (ctx.text(m.fn) !== 'Println') return
    if (ctx.bindingKind(m.pkg) !== 'import') return
    ctx.report(m.call)
  },
})

id

namespace/name. local/ needs no declaration; anything else goes in the config's namespaces. lanekeep/ is reserved.

language

Which languages the rule applies to — 'go', 'python', 'typescript', 'tsx', 'javascript', or an array. Defaults to ['typescript', 'tsx'].

This is load-bearing. The grammar is chosen by the file, not by the rule, and a rule does not run on a file whose language it does not name. Omit it on a Go rule and the rule simply never fires — silently.

card

Not documentation. message, remediation and examples are mandatory, because the card is what gets fed back to whoever has to act on the violation — increasingly an agent. remediation is the field worth the effort: it should say what to do, not restate the problem.

gates

Cheap rejections before any parsing. fileContains takes literal substrings; a file whose raw bytes never contain one is never parsed at all. Free performance on any rule with a distinctive token.

query

A tree-sitter query. This is the part that matters for speed. Rust matches it across a single shared parse, and only matches cross into your handler — typically two to three orders of magnitude fewer crossings than dispatching per node.

Write the narrowest query that captures what you need. check then only refines.

Several patterns in one query are alternatives; each match calls check once. That is how a rule covers context.Context and *context.Context, which differ by a pointer_type node.

check(ctx, m)

Ordinary TypeScript. m holds the captures, keyed by name without the @.

Host API

ctx.text(node) Source text of a node
ctx.kind(node) Its node kind
ctx.isNamed(node) Whether it is a named node
ctx.children(node) / ctx.namedChildren(node) Descend
ctx.parent(node) / ctx.ancestors(node) Ascend
ctx.closestAncestor(node, query) Nearest ancestor matching a query, with its captures
ctx.querySubtree(node, query) Run a query inside a subtree
ctx.line(node) / ctx.column(node) Position
ctx.bindingKind(node) How a name was introduced, or nothing if unresolved
ctx.resolvesToImport(node, { module, name }) Whether a name is a given import
ctx.isImportedFrom(node, module) Whether it came from a module
ctx.isShadowed(node) Whether an outer binding of the same name is hidden
ctx.report(node, { message? }) Report a violation
ctx.report(node, { fix }) Offer a replacement — see below
ctx.filePath / ctx.fileText / ctx.root The file being checked
ctx.readFile(path) / ctx.fileExists(path) Tracked reads, confined to the project root
ctx.emitFact(...) / ctx.facts / ctx.files Cross-file rules

Offering a fix

A fix is a field on the options object, not a method of its own:

ctx.report(node, {
  message: 'use a named export',
  fix: { node, text: 'export const parse = ...', safe: true },
})

Only a fix marked safe: true is applied by --fix. Anything else is a suggestion — shown, never written — because the cautious mistake costs a manual edit and the other one rewrites someone's code silently.

There is no clock, no randomness, no network, no fs, no process. Not restricted — absent from the context. Two runs over identical input produce byte-identical output, which is what makes the cache sound and what an agent reading the output twice depends on.

Binding resolution is the difference between a rule and a grep

if (ctx.bindingKind(m.pkg) !== 'import') return

Without that line, a local variable named fmt fires the rule. With it, the rule means what it says. Every supported language carries resolution, so this works the same on all of them.

bindingKind returns a string: import, const, let, var, param, function, class, catch-param, assignment, loop, context-manager, comprehension, type, receiver, type-param — or nothing when the name does not resolve.

Testing a rule

Built-in rules use RuleTester, driven through the real engine. See crates/lanekeep-rules/tests/ in the repository for the pattern. For a project rule, the fastest loop is:

lanekeep check --watch

Cross-file rules

A rule needing a whole-corpus view emits facts in check and consumes them in reduce. reduce never sees parse trees — only facts and the file list — which is what keeps cross-file rules parallel and cacheable. See docs/cross-file-rules.md.

Editor types

Not shipped yet. defineRule resolves inside the sandbox at run time, so rules execute correctly with nothing installed, but there is no package supplying TypeScript definitions for ctx. The table above and §6 of the architecture are the reference in the meantime.

Clone this wiki locally