-
Notifications
You must be signed in to change notification settings - Fork 0
Writing Rules
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.
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)
},
})namespace/name. local/ needs no declaration; anything else goes in the config's
namespaces. lanekeep/ is reserved.
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.
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.
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.
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.
Ordinary TypeScript. m holds the captures, keyed by name without the @.
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.fix(...) |
Offer a fix |
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 |
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.
if (ctx.bindingKind(m.pkg) !== 'import') returnWithout 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.
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 --watchA 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.
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.
Languages
In the repository