Skip to main content

lanekeep

Deterministic, AST-based architectural conformance checking for AI-generated and human-written code.

crates.io npm PyPI CI License: MIT OR Apache-2.0

lanekeep enforces the conventions that live in your team's heads and your reviewers' comments — the ones a language model cannot infer from the code it is shown. Every rule is a codified answer to "the agent keeps doing this wrong."

Checks TypeScript, JavaScript, Python, Go and Rust. Ships as a single static binary with no runtime dependency.


Quick start

Sixty seconds, from nothing to a rule catching something.

1. Install — whichever fits the project you are adding it to:

npm install --save-dev lanekeep
Python, Go, Homebrew, cargo, or a raw binary
pip install lanekeep                                      # Python
go get -tool github.com/fmsouza/lanekeep/cmd/lanekeep     # Go
brew install fmsouza/tap/lanekeep                         # macOS / Linux, system-wide
cargo install lanekeep-cli                                # from source

Or download from the releases page.

2. Scaffold a config and a first rule:

npx lanekeep init

That writes two files, both runnable:

lanekeep.json                 # what to check, and with which rules
lanekeep/rules/<starter>.ts   # a worked example you can edit

It detects whether the project is Go, Python or TypeScript and scaffolds accordingly — the right glob, a starter rule in that language, and a built-in worth having on.

3. Check:

npx lanekeep check
src/payment.ts:12:3 error [local/no-debugger] debugger statement
  → remove it before committing

✖ 1 error(s) across 1 file(s) checked

If it says 0 file(s) checked, nothing matched the config's include. The scaffold starts with src/**/*.{ts,tsx} — widen it to wherever your code actually lives.

That is the whole loop. Everything below is detail.


What it is

lanekeep is not a linter in the ESLint sense. ESLint enforces language-level correctness; lanekeep enforces project-specific conventions. The two do not overlap much, and lanekeep is not a replacement for either your linter or your formatter.

Rules are TypeScript programs. Here is one checking Go:

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)',
    },
  },

  // Matched in Rust, at native speed. Your code runs only on matches.
  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

    // The line that makes this a rule rather than a grep: a local variable
    // named `fmt` is not the standard library package.
    if (ctx.bindingKind(m.pkg) !== 'import') return

    ctx.report(m.call)
  },
})

Rules are TypeScript whatever they check — that is one embedded language, not a JavaScript bias. Rules need to be programs, because the conventions worth enforcing are too specific for any fixed vocabulary of predicates, and one language keeps the sandbox, the cache and the host API single-implementation.

Configuration is not TypeScript. lanekeep.json is plain data, so a Go or Python team never writes a .ts file except when authoring an actual rule.

check is ordinary TypeScript. Loop, accumulate state, build data structures, read other files, import shared helpers — there is no expressiveness ceiling and no DSL to learn beyond the query that gates it.

The card is not documentation. message, remediation and examples are mandatory, because they are what gets fed back to whoever has to act on the violation — increasingly an agent.

Editor types are not shipped yet. defineRule and defineConfig resolve inside lanekeep's sandbox at run time, so rules execute correctly, but there is no published package supplying TypeScript definitions for the host API — you will not get autocomplete on ctx today. docs/architecture.md §6 documents the full surface in the meantime.

Supported languages

Each guide covers installing lanekeep in that ecosystem, what to put in the config, which built-in rules apply, a worked custom rule, and the resolution behavior specific to it.

Language Guide Extensions
Go Go guide .go
Python Python guide .py, .pyi
Rust Rust guide .rs
TypeScript / JavaScript TypeScript and JavaScript guide .ts, .mts, .cts, .tsx, .js, .mjs, .cjs, .jsx

Every one carries syntactic binding resolution, so a rule can ask where a name came from rather than matching text — ctx.bindingKind, ctx.resolvesToImport and ctx.isShadowed answer for all of them.

The grammar is chosen by the file, not by the rule. A rule declares which languages it applies to and does not run on files of any other, defaulting to ['typescript', 'tsx'] when it says nothing. That default is the one thing to get right on a non-TypeScript rule: omit language on a Go rule and it silently never fires.

Configuration

lanekeep.json, at the project root. lanekeep init writes one for you, matched to the project it finds.

{
  "$schema": "https://raw.githubusercontent.com/fmsouza/lanekeep/main/schema/lanekeep.schema.json",

  "include": ["**/*.go"],
  "exclude": ["**/*_test.go"],

  "rules": [
    "lanekeep/no-package-init",
    { "rule": "lanekeep/no-restricted-imports", "options": { "restrictions": [
      { "module": "database/sql", "from": ["!internal/store/**"], "reason": "go through the store package" }
    ] } },
    "./lanekeep/rules/no-fmt-println.ts"
  ]
}

A string uses a rule as it comes; the object form calls it with options. $schema is what gives you completion and validation in your editor with nothing installed — VS Code and most others read it directly.

Rules are TypeScript, configuration is not. A rule is a program, and that is the point of the tool; saying which rules to run is data. A Go or Python team should not have to write a .ts file to do the second, which is why the config is JSON and only the rules are not.

Rule ids are namespaced. lanekeep/ is reserved for built-ins and local/ needs no declaration; any other prefix must be listed in namespaces, so a typo in an id is an error rather than a rule that silently never runs.

Ten rules ship built in — four for TypeScript and JavaScript, two each for Python, Go and Rust. See docs/built-in-rules.md for what each one checks and its options.

Configuring in TypeScript instead

lanekeep.config.ts still works, and is the better choice when the config computes something or shares a preset across repositories — composition is then ordinary import, with no bespoke extends mechanism to learn.

import { defineConfig } from 'lanekeep'
import noDefaultExport from 'lanekeep/no-default-export'
import noDebugger from './lanekeep/rules/no-debugger'

export default defineConfig({
  include: ['src/**/*.{ts,tsx}'],
  rules: [noDefaultExport, noDebugger],
})

Both formats compile to the same thing before anything reads them, so they cannot differ in behavior. lanekeep.json wins if a project somehow has both.

Using it

lanekeep check                  # the whole project
lanekeep check --staged         # only what is about to be committed
lanekeep check --since main     # only what changed against a ref
lanekeep check --watch          # re-check on every change, until Ctrl-C
lanekeep check --fix            # apply the safe fixes, report what is left
lanekeep check --profile        # where the run spent its time, per rule
lanekeep rules                  # what this project has configured
lanekeep explain <rule-id>      # one rule's card, without opening its source

--staged and --since are intersected with the config's include/exclude, and both skip cross-file rules — a whole-corpus rule over a subset gives a wrong answer rather than a smaller one, so they are skipped and named on stderr instead of quietly producing one.

Fixes. Only a fix its rule marked as behavior-preserving is applied. Anything else is a suggestion — shown, never written — because the cautious mistake costs a manual edit and the other one rewrites your code silently.

Suppressions carry a mandatory reason and an optional expiry. A directive that does not work says so, rather than silently doing nothing:

// lanekeep-ignore-next-line lanekeep/no-default-export reason: legacy entry point
export default parse

Run lanekeep check --report-unused-suppressions to find the ones that no longer silence anything.

Output. --format takes human (default), json (versioned, stable schema), sarif (GitHub code scanning) and agent — token-minimal, grouped by rule rather than by file, with each card stated once instead of once per violation. Diagnostics always go to stderr, so piping into a parser works even when something fails.

Exit codes: 0 clean, 1 violations found, 2 the checker could not run. A caller has to be able to tell "your code has problems" from "the tool is broken". --warn-only reports violations but exits 0, for a phased rollout.

In CI, editors and agents

lanekeep check --staged                 # pre-commit
lanekeep check --format sarif           # GitHub code scanning
lanekeep server                         # LSP, for any editor
lanekeep server --protocol mcp          # MCP, for an agent host

MCP exposes three tools — lanekeep_check, lanekeep_rules, lanekeep_explain — so an agent can ask what it broke and what the rule wants without shelling out and parsing text.

Worked examples for each, including SARIF upload and adopting on an existing codebase, are in CI and Editors.

How it stays fast with programmable rules

The usual problem with a native tool that runs JavaScript plugins is the boundary between them: dispatching into JS once per AST node means tens of thousands of crossings per file.

lanekeep dispatches once per query match instead. The tree-sitter query runs in Rust across a single shared parse; only matches reach your handler. That is typically two to three orders of magnitude fewer crossings, and it is the reason a Rust engine still earns its place once rules are TypeScript.

discover paths (globs, gitignore-aware)
  └─> for each file, in parallel:
        cache key ──hit──> validate tracked deps ──> cached violations + facts
                  └─miss─> path and raw-text gates reject before any parse
                           └─> parse ─> match queries in Rust
                               └─> invoke the TypeScript handler, per match only
  └─> reduce phase: cross-file rules consume facts only, never parse trees
  └─> filter suppressions ─> sort ─> report

A warm run with no changes executes no JavaScript at all — every file is a cache hit.

Violations are always sorted by (ruleId, file, line, column), and the sandbox withholds the clock and randomness, so two runs over identical input produce byte-identical output. An agent reading the output twice must not see reordering as change.

Installing without a package manager

Prebuilt for macOS on Apple silicon, Linux on x86-64 and arm64, and Windows on x86-64. The Linux binaries are built against glibc 2.17, so they run on anything from RHEL 7 onwards.

Intel macOS is not prebuilt — cargo install lanekeep-cli builds it from source, and both the npm launcher and the Homebrew formula say so rather than failing obscurely.

No runtime is required to run lanekeep, even though rules are written in TypeScript. Node, Python or Go is needed only to install it from that ecosystem, where it picks which binary to fetch. Nothing is pulled in as a dependency any of those ways.

The Go package is a small launcher, because Go can only install and pin things written in Go: it fetches the real binary on first use, verifies it against the release's published checksums, and caches it. Set LANEKEEP_BINARY to an already-installed lanekeep and it fetches nothing.

Documentation

The wiki is the place to start — it is task-shaped and organized by language.

Page Purpose
Getting Started Install and catch something, in about a minute
Configuration lanekeep.json, every field
Writing Rules Rule anatomy and the full host API
CI and Editors Pre-commit, GitHub Actions, LSP, MCP
Go · Python · Rust · TypeScript and JavaScript Per-language guides

In-repo, versioned with the code:

Document Purpose
docs/architecture.md The full design: execution model, host API, cache, milestones
docs/built-in-rules.md The rules lanekeep ships with, and their options
docs/cross-file-rules.md Writing a rule that needs a whole-corpus view
docs/adr/ Decision records: why the design is the way it is
CONTRIBUTING.md Setup, commands, and the pull request process
AGENTS.md How to work in this repository — for coding agents and humans alike
SECURITY.md Threat model and how to report a vulnerability
docs/releasing.md How a release is built, gated and published
CHANGELOG.md What changed, per release

Security

lanekeep is meant to run as a pre-commit hook and inside CI, which makes it a supply-chain target. Rules are executable code, so the posture is about confinement rather than absence:

  • No ambient authority. Rules run in an embedded QuickJS sandbox and reach exactly the host functions lanekeep exposes. fs, process, child_process, network and dynamic import are not restricted — they do not exist in the context.
  • No network access. Ever, in any mode, with no configuration that enables it.
  • Filesystem confinement. Reads go through a tracked ctx.readFile, confined to the project root. Writes happen only under --fix, only to matched files, only within reported ranges.
  • Bounded execution. A per-invocation timeout, a global run budget and a per-runtime memory ceiling, none disableable — a rule that hangs a pre-commit hook is indistinguishable from a broken tool. Breaching any of them cancels the run and exits 2, rather than reporting a partial result as a clean one.
  • Deterministic by construction. The sandbox withholds the clock and randomness, so a rule cannot introduce nondeterminism even by accident.

This bounds blast radius and makes third-party rule sets reviewable. It is not a boundary against someone who can already commit to the repository being checked. To report a vulnerability, see SECURITY.md.

Project status

Released and usable. The current version is on crates.io, npm, PyPI, Homebrew, and as a Go module — one build feeding every channel, so the bytes are identical whichever you use.

It is 0.x, and this repository treats that as semver does: a minor bump may break a public Rust API. Rule authors are insulated from that — ctx methods and the config shape are additive — but pin a version if you embed the crates.

Known gaps, stated rather than implied:

  • No editor types for rule authors yet (above).
  • The performance budgets in docs/architecture.md §15 are not met. They are targets, and that document says by how much and what the levers are. The tool is fast; the numbers are simply ambitious.
  • No type-aware analysis, by design. Binding resolution is syntactic — see §1 non-goals.

Contributing

Contributions are welcome, particularly new built-in rules and new host API surface. Start with CONTRIBUTING.md — ./scripts/setup-dev.sh installs everything and wires the git hooks, and just check is the same gate CI runs.

All work ships as squashed pull requests with Conventional Commits titles. main is protected and takes no direct pushes.

License

Licensed under either of

at your option.

Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.

Metadata

Release files for lanekeep 0.5.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Built distributions (wheels)

Table of built distributions (wheels) for lanekeep 0.5.0
File
lanekeep-0.5.0-py3-none-win_amd64.whl Python 3 none Windows x86-64 Details
lanekeep-0.5.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl Python 3 none Linux glibc 2.17+ x86-64 Details
lanekeep-0.5.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl Python 3 none Linux glibc 2.17+ ARM64 Details
lanekeep-0.5.0-py3-none-macosx_11_0_arm64.whl Python 3 none macOS 11.0+ ARM64 Details

Total release size: 10.8 MB

Release files / lanekeep-0.5.0-py3-none-win_amd64.whl

Download URL lanekeep-0.5.0-py3-none-win_amd64.whl
Size 2.8 MB
Tags Python 3 Windows x86-64
SHA-256 checksum
How to use checksums
44e9cde7db59a2b65356299312ce1467dc512fde9c54624dda83f6ccd33ef380
BLAKE2b-256 checksum
How to use checksums
05f29baa398427da194b2a72cd0bc0e729aa213327b2be4a1a13c831a3a7a22d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.3

Release files / lanekeep-0.5.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl

Download URL lanekeep-0.5.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Size 2.8 MB
Tags Linux glibc 2.17+ x86-64 Python 3
SHA-256 checksum
How to use checksums
0fb0effd825110aec3be68e37cf58c8024dbce3e2f7b86e47425bbacf8dc3032
BLAKE2b-256 checksum
How to use checksums
2097c124436bffdaa87a103f250cfd9f52b30190072cd1212716392bf570aa67
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.3

Release files / lanekeep-0.5.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl

Download URL lanekeep-0.5.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
Size 2.6 MB
Tags Linux glibc 2.17+ ARM64 Python 3
SHA-256 checksum
How to use checksums
ba5b2e2f06854c7c8301064e34b44b216c4717f77a041ae8aa00fceac04d82cb
BLAKE2b-256 checksum
How to use checksums
db3452789a939c10993beaaa0faa4a60eb7e561df764ae23ca25c6b93321c31f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.3

Release files / lanekeep-0.5.0-py3-none-macosx_11_0_arm64.whl

Download URL lanekeep-0.5.0-py3-none-macosx_11_0_arm64.whl
Size 2.6 MB
Tags Python 3 macOS 11.0+ ARM64
SHA-256 checksum
How to use checksums
307984e3dfa9a3538b651f132b542e229e5403ce78d6c5da3b26692890fe3a8f
BLAKE2b-256 checksum
How to use checksums
ce7018119974d98cea6db516cc9179fe83e4169285587ce9618f37bb4c32d296
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.3

Release history Release notifications | RSS feed

0.12.0

4 release files

0.11.0

4 release files

0.10.0

4 release files

0.9.0

4 release files

0.8.1

4 release files

0.8.0

4 release files

0.7.0

4 release files

0.6.1

4 release files

0.6.0

4 release files

This release

0.5.0 This release

4 release files

0.4.0

4 release files

0.3.2

4 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page