lanekeep 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,220 +2,355 @@
2
2
 
3
3
  **Deterministic, AST-based architectural conformance checking for AI-generated and human-written code.**
4
4
 
5
+ [![crates.io](https://img.shields.io/crates/v/lanekeep-cli?label=crates.io)](https://crates.io/crates/lanekeep-cli)
6
+ [![npm](https://img.shields.io/npm/v/lanekeep?label=npm)](https://www.npmjs.com/package/lanekeep)
7
+ [![PyPI](https://img.shields.io/pypi/v/lanekeep?label=pypi)](https://pypi.org/project/lanekeep/)
8
+ [![CI](https://github.com/fmsouza/lanekeep/actions/workflows/ci.yml/badge.svg)](https://github.com/fmsouza/lanekeep/actions/workflows/ci.yml)
5
9
  [![License: MIT OR Apache-2.0](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue.svg)](#license)
6
10
 
7
- > **Status: early development.** Nothing is released yet and the CLI described below is not
8
- > usable. The architecture is settled see [`docs/architecture.md`](docs/architecture.md) and
9
- > the work is tracked as a sequence of milestones. Do not depend on this yet.
11
+ lanekeep enforces the conventions that live in your team's heads and your reviewers' comments
12
+ the ones a language model cannot infer from the code it is shown. Every rule is a codified answer
13
+ to **"the agent keeps doing this wrong."**
14
+
15
+ Checks **TypeScript, JavaScript, Python, Go and Rust**. Ships as a single static binary with no runtime
16
+ dependency.
10
17
 
11
18
  ---
12
19
 
13
- ## What it is
20
+ ## Quick start
21
+
22
+ Sixty seconds, from nothing to a rule catching something.
23
+
24
+ **1. Install** — whichever fits the project you are adding it to:
25
+
26
+ ```bash
27
+ npm install --save-dev lanekeep
28
+ ```
29
+
30
+ <details>
31
+ <summary>Python, Go, Homebrew, cargo, or a raw binary</summary>
32
+
33
+ ```bash
34
+ pip install lanekeep # Python
35
+ go get -tool github.com/fmsouza/lanekeep/cmd/lanekeep # Go
36
+ brew install fmsouza/tap/lanekeep # macOS / Linux, system-wide
37
+ cargo install lanekeep-cli # from source
38
+ ```
39
+
40
+ Or download from the [releases page](https://github.com/fmsouza/lanekeep/releases).
41
+
42
+ </details>
43
+
44
+ **2. Scaffold a config and a first rule:**
14
45
 
15
- lanekeep is not a linter in the ESLint sense. ESLint enforces language-level correctness.
16
- lanekeep enforces *project-specific conventions* — the ones a language model has no way to infer
17
- from the code it is shown, because they live in your team's heads and your reviewers' comments.
46
+ ```bash
47
+ npx lanekeep init
48
+ ```
49
+
50
+ That writes two files, both runnable:
51
+
52
+ ```
53
+ lanekeep.json # what to check, and with which rules
54
+ lanekeep/rules/<starter>.ts # a worked example you can edit
55
+ ```
56
+
57
+ It detects whether the project is Go, Python or TypeScript and scaffolds accordingly — the
58
+ right glob, a starter rule in that language, and a built-in worth having on.
18
59
 
19
- Every rule is a codified answer to **"the agent keeps doing this wrong."**
60
+ **3. Check:**
61
+
62
+ ```bash
63
+ npx lanekeep check
64
+ ```
65
+
66
+ ```
67
+ src/payment.ts:12:3 error [local/no-debugger] debugger statement
68
+ → remove it before committing
69
+
70
+ ✖ 1 error(s) across 1 file(s) checked
71
+ ```
20
72
 
21
- Rules are TypeScript programs, written in the same language as the code they inspect:
73
+ > **If it says `0 file(s) checked`**, nothing matched the config's `include`. The scaffold starts
74
+ > with `src/**/*.{ts,tsx}` — widen it to wherever your code actually lives.
75
+
76
+ That is the whole loop. Everything below is detail.
77
+
78
+ ---
79
+
80
+ ## What it is
81
+
82
+ lanekeep is not a linter in the ESLint sense. ESLint enforces language-level correctness; lanekeep
83
+ enforces *project-specific* conventions. The two do not overlap much, and lanekeep is not a
84
+ replacement for either your linter or your formatter.
85
+
86
+ Rules are TypeScript programs. Here is one checking **Go**:
22
87
 
23
88
  ```ts
24
89
  import { defineRule } from 'lanekeep'
25
90
 
26
91
  export default defineRule({
27
- id: 'local/no-numeric-sizes',
92
+ id: 'local/no-fmt-println',
93
+ language: 'go',
28
94
  severity: 'error',
29
95
 
30
96
  card: {
31
- message: 'Literal numeric size inside makeStyles',
32
- remediation: 'Use theme.spacing.*, theme.borderRadius.* or theme.borders.*',
33
- examples: { bad: 'padding: 12', good: 'padding: theme.spacing.md' },
97
+ message: 'fmt.Println in library code',
98
+ remediation: 'use log/slog, so the output has a level and a destination',
99
+ examples: {
100
+ bad: 'fmt.Println("saved", count)',
101
+ good: 'slog.Info("saved", "count", count)',
102
+ },
34
103
  },
35
104
 
36
105
  // Matched in Rust, at native speed. Your code runs only on matches.
37
106
  query: `
38
- (pair
39
- key: (property_identifier) @prop
40
- value: [(number) (unary_expression operand: (number))] @value) @match
107
+ (call_expression
108
+ function: (selector_expression
109
+ operand: (identifier) @pkg
110
+ field: (field_identifier) @fn)) @call
41
111
  `,
42
112
 
43
113
  check(ctx, m) {
44
- if (!/^(padding|margin|gap|borderRadius)/.test(ctx.text(m.prop))) return
45
- if (Number(ctx.text(m.value)) === 0) return
114
+ if (ctx.text(m.pkg) !== 'fmt') return
115
+ if (ctx.text(m.fn) !== 'Println') return
46
116
 
47
- const call = ctx.closestAncestor(m.match, '(call_expression function: (identifier) @f)')
48
- if (!call) return
49
- if (!ctx.resolvesToImport(call.f, { module: '@rneui/themed', name: 'makeStyles' })) return
117
+ // The line that makes this a rule rather than a grep: a local variable
118
+ // named `fmt` is not the standard library package.
119
+ if (ctx.bindingKind(m.pkg) !== 'import') return
50
120
 
51
- ctx.report(m.match)
121
+ ctx.report(m.call)
52
122
  },
53
123
  })
54
124
  ```
55
125
 
126
+ **Rules are TypeScript whatever they check** — that is one embedded language, not a JavaScript
127
+ bias. Rules need to be *programs*, because the conventions worth enforcing are too specific for
128
+ any fixed vocabulary of predicates, and one language keeps the sandbox, the cache and the host
129
+ API single-implementation.
130
+
131
+ **Configuration is not TypeScript.** `lanekeep.json` is plain data, so a Go or Python team never
132
+ writes a `.ts` file except when authoring an actual rule.
133
+
56
134
  `check` is ordinary TypeScript. Loop, accumulate state, build data structures, read other files,
57
135
  import shared helpers — there is no expressiveness ceiling and no DSL to learn beyond the query
58
136
  that gates it.
59
137
 
60
- ## Why it exists
138
+ **The card is not documentation.** `message`, `remediation` and `examples` are mandatory, because
139
+ they are what gets fed back to whoever has to act on the violation — increasingly an agent.
61
140
 
62
- An agent that writes code against your codebase will violate your conventions confidently and
63
- repeatedly, because those conventions are invisible in the code it was shown. Telling it again in
64
- the next prompt does not scale. Encoding the convention as a rule does.
141
+ **Editor types ship with the npm package.** `npm install --save-dev lanekeep` gives you the
142
+ binary *and* TypeScript definitions for the whole host API, so `ctx` autocompletes and a typo'd
143
+ method is a compile error rather than a rule that throws in the sandbox. They are checked
144
+ against the engine's own registration, so they cannot drift from what actually exists.
65
145
 
66
- That makes the design constraints unusual for a static analyzer:
146
+ A Go, Python or Rust project that wants them can add the npm package as a dev dependency
147
+ purely for authoring — nothing about the checker needs Node.
67
148
 
68
- - **It runs in the inner loop.** Agents and developers invoke it after every edit, so a cold run
69
- on a couple of thousand files has a sub-second budget and a warm run has a sub-25ms one.
70
- - **Its output is read by a machine.** Violations are sorted deterministically, because an agent
71
- that reads the output twice must not see reordering as change.
72
- - **Every rule carries its own fix.** `message`, `remediation` and `examples` are mandatory
73
- fields, not documentation — they are the rule card that gets fed back to the agent.
149
+ ## Supported languages
74
150
 
75
- ## How it stays fast with programmable rules
151
+ Each guide covers installing lanekeep in that ecosystem, what to put in the config, which
152
+ built-in rules apply, a worked custom rule, and the resolution behavior specific to it.
76
153
 
77
- The usual problem with a native tool that runs JavaScript plugins is the boundary between them:
78
- dispatching into JS once per AST node means tens of thousands of crossings per file.
154
+ | Language | Guide | Extensions |
155
+ | --- | --- | --- |
156
+ | Go | **[Go guide](https://github.com/fmsouza/lanekeep/wiki/Go)** | `.go` |
157
+ | Python | **[Python guide](https://github.com/fmsouza/lanekeep/wiki/Python)** | `.py`, `.pyi` |
158
+ | Rust | **[Rust guide](https://github.com/fmsouza/lanekeep/wiki/Rust)** | `.rs` |
159
+ | TypeScript / JavaScript | **[TypeScript and JavaScript guide](https://github.com/fmsouza/lanekeep/wiki/TypeScript-and-JavaScript)** | `.ts`, `.mts`, `.cts`, `.tsx`, `.js`, `.mjs`, `.cjs`, `.jsx` |
79
160
 
80
- lanekeep dispatches once per **query match** instead. The tree-sitter query runs in Rust across a
81
- single shared parse; only matches reach your handler. That is typically two to three orders of
82
- magnitude fewer crossings, and it is the reason a Rust engine still earns its place once rules are
83
- TypeScript.
161
+ Every one carries syntactic binding resolution, so a rule can ask where a name came from rather
162
+ than matching text `ctx.bindingKind`, `ctx.resolvesToImport` and `ctx.isShadowed` answer for
163
+ all of them.
84
164
 
85
- ```
86
- discover paths (globs, gitignore-aware)
87
- └─> for each file, in parallel:
88
- cache key ──hit──> validate tracked deps ──> cached violations + facts
89
- └─miss─> path and raw-text gates reject before any parse
90
- └─> parse ─> match queries in Rust
91
- └─> invoke the TypeScript handler, per match only
92
- └─> reduce phase: cross-file rules consume facts only, never parse trees
93
- └─> filter suppressions ─> sort ─> report
94
- ```
165
+ **The grammar is chosen by the file, not by the rule.** A rule declares which languages it
166
+ applies to and does not run on files of any other, defaulting to `['typescript', 'tsx']` when it
167
+ says nothing. That default is the one thing to get right on a non-TypeScript rule: omit
168
+ `language` on a Go rule and it silently never fires.
95
169
 
96
- A warm run with no changes executes no JavaScript at all — every file is a cache hit.
170
+ ## Configuration
97
171
 
98
- ## Installation
172
+ `lanekeep.json`, at the project root. `lanekeep init` writes one for you, matched to the
173
+ project it finds.
99
174
 
100
- Pick whichever matches the project you are adding it to:
175
+ ```json
176
+ {
177
+ "$schema": "https://raw.githubusercontent.com/fmsouza/lanekeep/main/schema/lanekeep.schema.json",
101
178
 
102
- ```bash
103
- npm install --save-dev lanekeep # Node
104
- pip install lanekeep # Python
105
- brew install fmsouza/tap/lanekeep # macOS and Linux, system-wide
106
- cargo install lanekeep-cli # from source
107
- ```
179
+ "include": ["**/*.go"],
180
+ "exclude": ["**/*_test.go"],
108
181
 
109
- For a Go project, pin it in `go.mod` alongside your other tools:
110
-
111
- ```bash
112
- go get -tool github.com/fmsouza/lanekeep/cmd/lanekeep
182
+ "rules": [
183
+ "lanekeep/no-package-init",
184
+ { "rule": "lanekeep/no-restricted-imports", "options": { "restrictions": [
185
+ { "module": "database/sql", "from": ["!internal/store/**"], "reason": "go through the store package" }
186
+ ] } },
187
+ "./lanekeep/rules/no-fmt-println.ts"
188
+ ]
189
+ }
113
190
  ```
114
191
 
115
- then `go tool lanekeep check ./...`. Go can only install and pin things written in Go, so
116
- that package is a small launcher which fetches the real binary on first use, verifies it
117
- against the release's published checksums, and caches it. Set `LANEKEEP_BINARY` to an
118
- already-installed lanekeep and it fetches nothing.
192
+ A string uses a rule as it comes; the object form calls it with options. `$schema` is what
193
+ gives you **completion and validation in your editor with nothing installed** VS Code and
194
+ most others read it directly.
119
195
 
120
- Or download a binary from the [releases page](https://github.com/fmsouza/lanekeep/releases).
196
+ **Rules are TypeScript, configuration is not.** A rule is a program, and that is the point of
197
+ the tool; saying which rules to run is data. A Go or Python team should not have to write a
198
+ `.ts` file to do the second, which is why the config is JSON and only the rules are not.
121
199
 
122
- A single static binary with the JavaScript engine compiled in. **No runtime is required to run
123
- lanekeep**, even though rules are written in TypeScript Node, Python or Go is needed only to
124
- install it from that ecosystem, where it picks which binary to fetch. Nothing is pulled in as a
125
- dependency any of those ways.
200
+ Rule ids are namespaced. `lanekeep/` is reserved for built-ins and `local/` needs no
201
+ declaration; any other prefix must be listed in `namespaces`, so a typo in an id is an error
202
+ rather than a rule that silently never runs.
126
203
 
127
- Prebuilt for macOS on Apple silicon, Linux on x86-64 and arm64, and Windows on x86-64. The
128
- Linux binaries are built against glibc 2.17, so they run on anything from RHEL 7 onwards.
129
- Intel macOS is not prebuilt — `cargo install lanekeep-cli` builds it from source, and both the
130
- npm launcher and the Homebrew formula say so rather than failing obscurely.
204
+ Ten rules ship built in four for TypeScript and JavaScript, two each for Python, Go and Rust. See
205
+ [`docs/built-in-rules.md`](docs/built-in-rules.md) for what each one checks and its options.
131
206
 
132
- See [`docs/releasing.md`](docs/releasing.md) for how a release is cut.
207
+ <details>
208
+ <summary>Configuring in TypeScript instead</summary>
133
209
 
134
- ## What it looks like
210
+ `lanekeep.config.ts` still works, and is the better choice when the config computes something
211
+ or shares a preset across repositories — composition is then ordinary `import`, with no
212
+ bespoke `extends` mechanism to learn.
135
213
 
136
- ```
137
- $ lanekeep check
138
- src/also.ts:2:1 error [lanekeep/no-default-export] default export
139
- use a named export, so the symbol has one name every importer must use
140
- src/bad.ts:2:1 error [lanekeep/no-default-export] default export
141
- → use a named export, so the symbol has one name every importer must use
214
+ ```ts
215
+ import { defineConfig } from 'lanekeep'
216
+ import noDefaultExport from 'lanekeep/no-default-export'
217
+ import noDebugger from './lanekeep/rules/no-debugger'
142
218
 
143
- 2 error(s) across 2 file(s) checked
219
+ export default defineConfig({
220
+ include: ['src/**/*.{ts,tsx}'],
221
+ rules: [noDefaultExport, noDebugger],
222
+ })
144
223
  ```
145
224
 
146
- Rules may offer a fix, applied with `--fix`:
225
+ Both formats compile to the same thing before anything reads them, so they cannot differ in
226
+ behavior. `lanekeep.json` wins if a project somehow has both.
147
227
 
148
- ```
149
- $ lanekeep check --fix
150
- fixed 2 violation(s) in 2 file(s)
228
+ </details>
229
+
230
+ ## Using it
231
+
232
+ ```bash
233
+ lanekeep check # the whole project
234
+ lanekeep check --staged # only what is about to be committed
235
+ lanekeep check --since main # only what changed against a ref
236
+ lanekeep check --watch # re-check on every change, until Ctrl-C
237
+ lanekeep check --fix # apply the safe fixes, report what is left
238
+ lanekeep check --profile # where the run spent its time, per rule
239
+ lanekeep rules # what this project has configured
240
+ lanekeep explain <rule-id> # one rule's card, without opening its source
151
241
  ```
152
242
 
153
- Only fixes a rule marked as behavior-preserving are applied. Anything else is a suggestion —
154
- shown, never written because the cautious mistake costs a manual edit and the other one
155
- rewrites your code silently.
243
+ `--staged` and `--since` are intersected with the config's `include`/`exclude`, and both **skip
244
+ cross-file rules**a whole-corpus rule over a subset gives a wrong answer rather than a smaller
245
+ one, so they are skipped and named on stderr instead of quietly producing one.
156
246
 
157
- Suppressions carry a mandatory reason and an optional expiry, and a directive that does not
158
- work says so a missing reason, a bare rule id, or an unreadable date is reported rather
159
- than silently doing nothing:
247
+ **Fixes.** Only a fix its rule marked as behavior-preserving is applied. Anything else is a
248
+ suggestion shown, never written because the cautious mistake costs a manual edit and the other
249
+ one rewrites your code silently.
250
+
251
+ **Suppressions** carry a mandatory reason and an optional expiry. A directive that does not work
252
+ says so, rather than silently doing nothing:
160
253
 
161
254
  ```ts
162
255
  // lanekeep-ignore-next-line lanekeep/no-default-export reason: legacy entry point
163
256
  export default parse
164
257
  ```
165
258
 
166
- ```
167
- $ lanekeep check --report-unused-suppressions
168
- ```
259
+ Run `lanekeep check --report-unused-suppressions` to find the ones that no longer silence
260
+ anything.
169
261
 
170
- To start from nothing:
262
+ **Output.** `--format` takes `human` (default), `json` (versioned, stable schema), `sarif` (GitHub
263
+ code scanning) and `agent` — token-minimal, grouped by rule rather than by file, with each card
264
+ stated once instead of once per violation. Diagnostics always go to stderr, so piping into a
265
+ parser works even when something fails.
171
266
 
172
- ```
173
- $ lanekeep init # a config plus a first rule, both runnable
174
- ```
267
+ **Exit codes:** `0` clean, `1` violations found, `2` the checker could not run. A caller has to be
268
+ able to tell "your code has problems" from "the tool is broken". `--warn-only` reports violations
269
+ but exits `0`, for a phased rollout.
175
270
 
176
- To find out where a run spent its time — the split says whether the query or the code is
177
- the problem:
271
+ ## In CI, editors and agents
178
272
 
179
- ```
180
- $ lanekeep check --profile
273
+ ```bash
274
+ lanekeep check --staged # pre-commit
275
+ lanekeep check --format sarif # GitHub code scanning
276
+ lanekeep server # LSP, for any editor
277
+ lanekeep server --protocol mcp # MCP, for an agent host
181
278
  ```
182
279
 
183
- To find out what a rule wants without opening its source:
280
+ MCP exposes three tools `lanekeep_check`, `lanekeep_rules`, `lanekeep_explain` so an agent
281
+ can ask what it broke and what the rule wants without shelling out and parsing text.
184
282
 
185
- ```
186
- $ lanekeep explain lanekeep/no-default-export
187
- $ lanekeep rules --json
188
- ```
283
+ Worked examples for each, including SARIF upload and adopting on an existing codebase, are in
284
+ **[CI and Editors](https://github.com/fmsouza/lanekeep/wiki/CI-and-Editors)**.
189
285
 
190
- For fast feedback on what you touched:
286
+ ## How it stays fast with programmable rules
287
+
288
+ The usual problem with a native tool that runs JavaScript plugins is the boundary between them:
289
+ dispatching into JS once per AST node means tens of thousands of crossings per file.
290
+
291
+ lanekeep dispatches once per **query match** instead. The tree-sitter query runs in Rust across a
292
+ single shared parse; only matches reach your handler. That is typically two to three orders of
293
+ magnitude fewer crossings, and it is the reason a Rust engine still earns its place once rules are
294
+ TypeScript.
191
295
 
192
296
  ```
193
- $ lanekeep check --staged # what is about to be committed
194
- $ lanekeep check --since main # what changed against a ref
297
+ discover paths (globs, gitignore-aware)
298
+ └─> for each file, in parallel:
299
+ cache key ──hit──> validate tracked deps ──> cached violations + facts
300
+ └─miss─> path and raw-text gates reject before any parse
301
+ └─> parse ─> match queries in Rust
302
+ └─> invoke the TypeScript handler, per match only
303
+ └─> reduce phase: cross-file rules consume facts only, never parse trees
304
+ └─> filter suppressions ─> sort ─> report
195
305
  ```
196
306
 
197
- Both are intersected with the config's `include`/`exclude`, and both skip cross-file rules
198
- a whole-corpus rule over a subset gives a wrong answer, not a smaller one, so they are
199
- skipped and named on stderr rather than quietly producing one.
307
+ A warm run with no changes executes no JavaScript at all — every file is a cache hit.
308
+
309
+ Violations are always sorted by `(ruleId, file, line, column)`, and the sandbox withholds the clock
310
+ and randomness, so two runs over identical input produce byte-identical output. An agent reading
311
+ the output twice must not see reordering as change.
312
+
313
+ ## Installing without a package manager
314
+
315
+ Prebuilt for macOS on Apple silicon, Linux on x86-64 and arm64, and Windows on x86-64. The Linux
316
+ binaries are built against **glibc 2.17**, so they run on anything from RHEL 7 onwards.
200
317
 
201
- Exit `0` when clean, `1` when violations are found, `2` when the checker could not run —
202
- a caller has to be able to tell "your code has problems" from "the tool is broken".
203
- Four output formats: `human` (default), `json` (versioned, stable schema), `sarif` (GitHub
204
- code scanning), and `agent` token-minimal, grouped by rule rather than by file, with each
205
- rule's card stated once instead of once per violation. Diagnostics always go to stderr, so
206
- piping into a parser works even when something fails.
318
+ Intel macOS is not prebuilt — `cargo install lanekeep-cli` builds it from source, and both the npm
319
+ launcher and the Homebrew formula say so rather than failing obscurely.
320
+
321
+ **No runtime is required to run lanekeep**, even though rules are written in TypeScript. Node,
322
+ Python or Go is needed only to install it from that ecosystem, where it picks which binary to
323
+ fetch. Nothing is pulled in as a dependency any of those ways.
324
+
325
+ The Go package is a small launcher, because Go can only install and pin things written in Go: it
326
+ fetches the real binary on first use, verifies it against the release's published checksums, and
327
+ caches it. Set `LANEKEEP_BINARY` to an already-installed lanekeep and it fetches nothing.
207
328
 
208
329
  ## Documentation
209
330
 
331
+ **The [wiki](https://github.com/fmsouza/lanekeep/wiki) is the place to start** — it is task-shaped and organized by language.
332
+
333
+ | Page | Purpose |
334
+ | --- | --- |
335
+ | [Getting Started](https://github.com/fmsouza/lanekeep/wiki/Getting-Started) | Install and catch something, in about a minute |
336
+ | [Configuration](https://github.com/fmsouza/lanekeep/wiki/Configuration) | `lanekeep.json`, every field |
337
+ | [Writing Rules](https://github.com/fmsouza/lanekeep/wiki/Writing-Rules) | Rule anatomy and the full host API |
338
+ | [CI and Editors](https://github.com/fmsouza/lanekeep/wiki/CI-and-Editors) | Pre-commit, GitHub Actions, LSP, MCP |
339
+ | [Go](https://github.com/fmsouza/lanekeep/wiki/Go) · [Python](https://github.com/fmsouza/lanekeep/wiki/Python) · [Rust](https://github.com/fmsouza/lanekeep/wiki/Rust) · [TypeScript and JavaScript](https://github.com/fmsouza/lanekeep/wiki/TypeScript-and-JavaScript) | Per-language guides |
340
+
341
+ In-repo, versioned with the code:
342
+
210
343
  | Document | Purpose |
211
344
  | --- | --- |
212
345
  | [`docs/architecture.md`](docs/architecture.md) | The full design: execution model, host API, cache, milestones |
213
346
  | [`docs/built-in-rules.md`](docs/built-in-rules.md) | The rules lanekeep ships with, and their options |
214
347
  | [`docs/cross-file-rules.md`](docs/cross-file-rules.md) | Writing a rule that needs a whole-corpus view |
215
- | [`AGENTS.md`](AGENTS.md) | How to work in this repository for coding agents and humans alike |
348
+ | [`docs/adr/`](docs/adr/) | Decision records: why the design is the way it is |
216
349
  | [`CONTRIBUTING.md`](CONTRIBUTING.md) | Setup, commands, and the pull request process |
350
+ | [`AGENTS.md`](AGENTS.md) | How to work in this repository — for coding agents and humans alike |
217
351
  | [`SECURITY.md`](SECURITY.md) | Threat model and how to report a vulnerability |
218
352
  | [`docs/releasing.md`](docs/releasing.md) | How a release is built, gated and published |
353
+ | [`CHANGELOG.md`](CHANGELOG.md) | What changed, per release |
219
354
 
220
355
  ## Security
221
356
 
@@ -228,10 +363,10 @@ Rules are executable code, so the posture is about confinement rather than absen
228
363
  - **No network access.** Ever, in any mode, with no configuration that enables it.
229
364
  - **Filesystem confinement.** Reads go through a tracked `ctx.readFile`, confined to the project
230
365
  root. Writes happen only under `--fix`, only to matched files, only within reported ranges.
231
- - **Bounded execution.** A per-invocation timeout, a 15-second global run budget and a per-runtime
232
- memory ceiling, none disableable — a rule that hangs a pre-commit hook is indistinguishable from
233
- a broken tool. Breaching any of them cancels the run and exits `2`, rather than reporting a
234
- partial result as a clean one.
366
+ - **Bounded execution.** A per-invocation timeout, a global run budget and a per-runtime memory
367
+ ceiling, none disableable — a rule that hangs a pre-commit hook is indistinguishable from a
368
+ broken tool. Breaching any of them cancels the run and exits `2`, rather than reporting a partial
369
+ result as a clean one.
235
370
  - **Deterministic by construction.** The sandbox withholds the clock and randomness, so a rule
236
371
  cannot introduce nondeterminism even by accident.
237
372
 
@@ -239,14 +374,33 @@ This bounds blast radius and makes third-party rule sets reviewable. It is not a
239
374
  someone who can already commit to the repository being checked. To report a vulnerability, see
240
375
  [`SECURITY.md`](SECURITY.md).
241
376
 
377
+ ## Project status
378
+
379
+ **Released and usable.** The current version is on [crates.io](https://crates.io/crates/lanekeep-cli),
380
+ [npm](https://www.npmjs.com/package/lanekeep), [PyPI](https://pypi.org/project/lanekeep/), Homebrew,
381
+ and as a Go module — one build feeding every channel, so the bytes are identical whichever you use.
382
+
383
+ It is **0.x**, and this repository treats that as semver does: a minor bump may break a public Rust
384
+ API. Rule authors are insulated from that — `ctx` methods and the config shape are additive — but
385
+ pin a version if you embed the crates.
386
+
387
+ Known gaps, stated rather than implied:
388
+
389
+ - **No editor types for rule authors yet** (above).
390
+ - **The performance budgets in [`docs/architecture.md`](docs/architecture.md) §15 are not met.**
391
+ They are targets, and that document says by how much and what the levers are. The tool is fast;
392
+ the numbers are simply ambitious.
393
+ - **No type-aware analysis**, by design. Binding resolution is syntactic — see §1 non-goals.
394
+
242
395
  ## Contributing
243
396
 
244
- Contributions are welcome, particularly new built-in rules and new host API surface. Start
245
- with [`CONTRIBUTING.md`](CONTRIBUTING.md) — `./scripts/setup-dev.sh` installs everything and
246
- wires the git hooks.
397
+ Contributions are welcome, particularly new built-in rules and new host API surface. Start with
398
+ [`CONTRIBUTING.md`](CONTRIBUTING.md) — `./scripts/setup-dev.sh` installs everything and wires the
399
+ git hooks, and `just check` is the same gate CI runs.
247
400
 
248
- All work ships as squashed pull requests with [Conventional Commits](https://www.conventionalcommits.org/)
249
- titles. `main` is protected and takes no direct pushes.
401
+ All work ships as squashed pull requests with
402
+ [Conventional Commits](https://www.conventionalcommits.org/) titles. `main` is protected and takes
403
+ no direct pushes.
250
404
 
251
405
  ## License
252
406
 
package/builtin.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Types reached through the `typesVersions` mapping in `package.json`.
3
+ *
4
+ * That mapping points *every* specifier here, the bare `lanekeep` included — TypeScript's
5
+ * `"*"` pattern does not exclude the package root, and a narrower pattern would have to
6
+ * predict what future built-ins are called. So this file is a superset: it re-exports
7
+ * everything `index.d.ts` has, and adds the default export a built-in subpath needs.
8
+ *
9
+ * A `declare module 'lanekeep/*'` block inside `index.d.ts` would have been the obvious way
10
+ * to do this and does nothing at all: a `declare module` inside a file that has its own
11
+ * imports or exports is module augmentation, not an ambient declaration, so TypeScript
12
+ * ignores it and the import stays unresolved. That failed silently until a compile test
13
+ * caught it.
14
+ *
15
+ * The default covers both shapes a built-in can take, because which one it is cannot be known
16
+ * from the specifier: a rule taking options is a factory — `noRestrictedImports({ ... })` —
17
+ * and one taking none is the rule itself.
18
+ */
19
+ export * from './index'
20
+
21
+ import type { Rule } from './index'
22
+
23
+ declare const rule: Rule & ((options?: Record<string, unknown>) => Rule)
24
+ export default rule
package/index.d.ts ADDED
@@ -0,0 +1,308 @@
1
+ /**
2
+ * Type definitions for authoring lanekeep rules.
3
+ *
4
+ * These describe the host API a rule reaches inside lanekeep's sandbox. Nothing here runs in
5
+ * Node: `defineRule` and `defineConfig` are identity functions whose only job is to give the
6
+ * compiler something to check against, and `RuleContext` is provided by lanekeep at run time.
7
+ *
8
+ * Every member below is asserted against the host's own registration in
9
+ * `crates/lanekeep-js/tests/host_types.rs`. A method that exists here and not there — or the
10
+ * reverse — fails that test, because a definition that drifts from the engine is worse than
11
+ * none: it produces confident autocomplete for something that does not exist.
12
+ */
13
+
14
+ /**
15
+ * A node in the parse tree.
16
+ *
17
+ * Deliberately opaque. Nodes cross into the sandbox as integer handles rather than objects,
18
+ * which is one of the one-way doors in the architecture — and the reason this is a branded
19
+ * type rather than `number` is that **the root node's handle is `0`**. Written as a plain
20
+ * number, `if (!node)` looks like a null check and silently discards the root, which is how
21
+ * a rule loses its whole top-level case without any error.
22
+ *
23
+ * Compare against `undefined` explicitly.
24
+ */
25
+ export type Node = number & { readonly __lanekeepNode: unique symbol }
26
+
27
+ /** How a name was introduced, as `ctx.bindingKind` reports it. */
28
+ export type BindingKind =
29
+ | 'import'
30
+ | 'const'
31
+ | 'let'
32
+ | 'var'
33
+ | 'param'
34
+ | 'function'
35
+ | 'class'
36
+ | 'catch-param'
37
+ | 'assignment'
38
+ | 'loop'
39
+ | 'context-manager'
40
+ | 'comprehension'
41
+ | 'type'
42
+ | 'receiver'
43
+ | 'type-param'
44
+ | 'module'
45
+ | 'trait'
46
+
47
+ /** A language lanekeep can parse. */
48
+ export type LanguageId = 'typescript' | 'tsx' | 'javascript' | 'python' | 'go' | 'rust'
49
+
50
+ /** How serious a violation is. */
51
+ export type Severity = 'error' | 'warn' | 'off'
52
+
53
+ /**
54
+ * The captures of one query match, keyed by capture name without the `@`.
55
+ *
56
+ * A capture that did not participate in the match is absent, which is why the values are
57
+ * optional — an alternation like `[(a) (b)] @x` binds `@x` either way, but two separate
58
+ * patterns capturing different names do not.
59
+ */
60
+ export type Match = Record<string, Node | undefined>
61
+
62
+ /**
63
+ * What a rule tells whoever has to act on the violation — increasingly an agent.
64
+ *
65
+ * Not documentation, and not optional. `remediation` is the field worth the effort: it should
66
+ * say what to do, not restate the problem.
67
+ */
68
+ export interface RuleCard {
69
+ /** What is wrong, in a few words. */
70
+ message: string
71
+ /** What to do about it. */
72
+ remediation: string
73
+ /** One example each way. */
74
+ examples: {
75
+ bad: string
76
+ good: string
77
+ }
78
+ }
79
+
80
+ /** Cheap rejections applied before a file is parsed. */
81
+ export interface Gates {
82
+ /**
83
+ * Literal substrings a file's raw bytes must contain. A file missing any one of them is
84
+ * never parsed.
85
+ *
86
+ * **This is an *and*, not an *or*.** A rule matching either of two tokens cannot express
87
+ * its gate as `['a', 'b']` — that rejects any file containing only one, which is usually
88
+ * most of them, and the rule then reports nothing while looking healthy. There is no `or`
89
+ * form; omit the gate when no single substring covers every case.
90
+ */
91
+ fileContains?: string[]
92
+ }
93
+
94
+ /** A replacement a rule offers for a violation. */
95
+ export interface Fix {
96
+ /** The node whose text is replaced. */
97
+ node: Node
98
+ /** What to replace it with. */
99
+ text: string
100
+ /**
101
+ * Whether the fix preserves behavior.
102
+ *
103
+ * Only a fix marked `true` is applied by `--fix`. Anything else is a suggestion — shown,
104
+ * never written — because the cautious mistake costs a manual edit and the other one
105
+ * rewrites someone's code silently.
106
+ */
107
+ safe?: boolean
108
+ }
109
+
110
+ /** Options for a single report. */
111
+ export interface ReportOptions {
112
+ /** Overrides the card's `message` for this one violation. */
113
+ message?: string
114
+ /** A replacement to offer. */
115
+ fix?: Fix
116
+ }
117
+
118
+ /**
119
+ * A fact a rule emits for the reduce phase.
120
+ *
121
+ * `kind` is required and must be non-empty, because it is what `ctx.facts('...')` filters on.
122
+ * A fact without one could never be retrieved, so emitting it is always a mistake — and a
123
+ * silent one, since the rule would look like it was working right up until `reduce` found
124
+ * nothing. lanekeep throws rather than accept it.
125
+ */
126
+ export interface Fact {
127
+ kind: string
128
+ [key: string]: unknown
129
+ }
130
+
131
+ /** A fact as `reduce` receives it, with the file that emitted it. */
132
+ export interface EmittedFact extends Fact {
133
+ /** Path of the file this came from, relative to the project root. */
134
+ file: string
135
+ }
136
+
137
+ /** What a rule's `check` handler reaches. */
138
+ export interface RuleContext {
139
+ /** Path of the file being checked, relative to the project root. */
140
+ readonly filePath: string
141
+ /** The whole file, as text. */
142
+ readonly fileText: string
143
+ /** The tree's root node. Its handle is `0` — see {@link Node}. */
144
+ readonly root: Node
145
+
146
+ /** The node's kind, as tree-sitter names it. */
147
+ kind(node: Node): string
148
+ /** The source text the node spans. */
149
+ text(node: Node): string
150
+ /** Whether this is a named node rather than an anonymous token. */
151
+ isNamed(node: Node): boolean
152
+ /** One-based line of the node's start. */
153
+ line(node: Node): number
154
+ /** One-based column of the node's start. */
155
+ column(node: Node): number
156
+
157
+ /** The node's parent, or `undefined` at the root. */
158
+ parent(node: Node): Node | undefined
159
+ /** Every child, including anonymous tokens. */
160
+ children(node: Node): Node[]
161
+ /** Named children only. */
162
+ namedChildren(node: Node): Node[]
163
+ /** Every ancestor, innermost first. */
164
+ ancestors(node: Node): Node[]
165
+
166
+ /**
167
+ * Whether an identifier resolves to a given import.
168
+ *
169
+ * Handles aliasing, so `import { makeStyles as ms }` resolves correctly. This is the call
170
+ * that separates a rule from a grep: a text match both misses the alias and fires on a
171
+ * local of the same name.
172
+ *
173
+ * @param name Which export. Omit to match the module regardless of which name was taken.
174
+ */
175
+ resolvesToImport(node: Node, module: string, name?: string): boolean
176
+ /** Whether an identifier came from a module matching this glob. */
177
+ isImportedFrom(node: Node, pattern: string): boolean
178
+ /** How the name was introduced, or `undefined` when it does not resolve. */
179
+ bindingKind(node: Node): BindingKind | undefined
180
+ /** Whether an outer binding of the same name is hidden by this one. */
181
+ isShadowed(node: Node): boolean
182
+
183
+ /** Run a query inside a subtree. */
184
+ querySubtree(node: Node, query: string): Match[]
185
+ /** The nearest ancestor matching a query, with its captures. */
186
+ closestAncestor(node: Node, query: string): Match | undefined
187
+
188
+ /**
189
+ * Read another file, relative to the project root.
190
+ *
191
+ * Tracked: the read becomes part of the cache key, so a change to that file invalidates
192
+ * this one's result. Confined to the project root; `undefined` when absent or outside.
193
+ */
194
+ readFile(path: string): string | undefined
195
+ /** Whether a file exists, tracked the same way. */
196
+ fileExists(path: string): boolean
197
+
198
+ /** Emit a fact for the reduce phase. */
199
+ emitFact(fact: Fact): void
200
+ /** Facts emitted so far, optionally filtered by `kind`. */
201
+ facts(kind?: string): EmittedFact[]
202
+
203
+ /** Report a violation at a node. */
204
+ report(at: Node, message?: string | ReportOptions): void
205
+ }
206
+
207
+ /** A violation the reduce phase reports, which has no node to point at. */
208
+ export interface ReduceLocation {
209
+ /** Path relative to the project root. */
210
+ file: string
211
+ /** One-based. */
212
+ line?: number
213
+ /** One-based. */
214
+ column?: number
215
+ }
216
+
217
+ /**
218
+ * What a rule's `reduce` handler reaches.
219
+ *
220
+ * Deliberately smaller than {@link RuleContext}: **the reduce phase never touches parse
221
+ * trees.** Facts are small and serializable, which is what keeps cross-file rules parallel
222
+ * and cacheable — handing a tree to `reduce` would make the whole corpus resident.
223
+ */
224
+ export interface ReduceContext {
225
+ /** Every file the run checked, relative to the project root. */
226
+ readonly files: string[]
227
+ /** Facts from every file, optionally filtered by `kind`. */
228
+ facts(kind?: string): EmittedFact[]
229
+ /** Report a violation against a file. */
230
+ report(at: ReduceLocation, message?: string | ReportOptions): void
231
+ }
232
+
233
+ /** A rule, as `defineRule` takes it. */
234
+ export interface Rule {
235
+ /**
236
+ * Namespaced identifier, as `namespace/name`.
237
+ *
238
+ * `local/` needs no declaration and `lanekeep/` is reserved for built-ins; any other
239
+ * namespace must be listed in the config's `namespaces`.
240
+ */
241
+ id: string
242
+ /**
243
+ * Which languages this rule applies to.
244
+ *
245
+ * **Defaults to `['typescript', 'tsx']`**, and this is the field most worth getting right
246
+ * on a rule for anything else. The grammar is chosen by the file, not by the rule, and a
247
+ * rule does not run on a file whose language it does not name — so omitting this on a Go
248
+ * or Rust rule means it silently never fires.
249
+ */
250
+ language?: LanguageId | LanguageId[]
251
+ /** How serious a violation is, before any config override. */
252
+ severity: Severity
253
+ /** What the rule tells whoever has to act on it. */
254
+ card: RuleCard
255
+ /** Cheap rejections before parsing. */
256
+ gates?: Gates
257
+ /**
258
+ * The tree-sitter query gating the handler.
259
+ *
260
+ * Rust matches it across a single shared parse and only matches reach `check`, which is
261
+ * what keeps a JavaScript rule affordable. Write the narrowest query that captures what
262
+ * you need; `check` then only refines.
263
+ */
264
+ query: string
265
+ /** A per-invocation budget overriding the default, in milliseconds. */
266
+ timeout?: number
267
+ /** Called once per query match. */
268
+ check?(ctx: RuleContext, match: Match): void
269
+ /** Called once per run, after every file, with facts only. */
270
+ reduce?(ctx: ReduceContext): void
271
+ }
272
+
273
+ /** A lanekeep configuration, as `defineConfig` takes it. */
274
+ export interface Config {
275
+ /** Globs selecting files to check, relative to the project root. */
276
+ include?: string[]
277
+ /** Globs removing files from that selection. */
278
+ exclude?: string[]
279
+ /** Rule-id namespaces this project uses beyond `local`. */
280
+ namespaces?: string[]
281
+ /** Override a rule's own severity, by id. */
282
+ severity?: Record<string, Severity>
283
+ /** Execution budgets, in milliseconds. */
284
+ timeouts?: {
285
+ /** Per rule invocation. */
286
+ rule?: number
287
+ /** Wall-clock, for the whole run. */
288
+ global?: number
289
+ }
290
+ /** The rules to run, in order. */
291
+ rules: Rule[]
292
+ }
293
+
294
+ /**
295
+ * Define a rule.
296
+ *
297
+ * An identity function. It exists so the compiler checks the object against {@link Rule}
298
+ * where it is written, rather than reporting a mismatch from wherever it is imported.
299
+ */
300
+ export declare function defineRule(rule: Rule): Rule
301
+
302
+ /**
303
+ * Define a configuration.
304
+ *
305
+ * An identity function, for the same reason as {@link defineRule}. Most projects will write
306
+ * `lanekeep.json` instead — configuration is data, and only rules need to be programs.
307
+ */
308
+ export declare function defineConfig(config: Config): Config
package/index.js ADDED
@@ -0,0 +1,15 @@
1
+ // The runtime half of the authoring package.
2
+ //
3
+ // Rules never execute in Node — lanekeep evaluates them in its own sandbox, where `lanekeep`
4
+ // resolves to a host module rather than to this file. These exports exist so that a tool
5
+ // which *does* load a rule under Node (a bundler, a test runner, an editor's type server
6
+ // following the import) finds something coherent instead of a missing module.
7
+ //
8
+ // Identity functions, which is also what they are inside the sandbox: their entire job is to
9
+ // give the compiler a place to check the object against a type.
10
+ 'use strict'
11
+
12
+ const identity = (value) => value
13
+
14
+ module.exports = { defineRule: identity, defineConfig: identity }
15
+ module.exports.default = module.exports
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lanekeep",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Deterministic, AST-based architectural conformance checking",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "repository": {
@@ -14,15 +14,27 @@
14
14
  "files": [
15
15
  "bin/lanekeep",
16
16
  "resolve.js",
17
+ "index.js",
18
+ "index.d.ts",
19
+ "builtin.d.ts",
17
20
  "README.md"
18
21
  ],
19
22
  "engines": {
20
23
  "node": ">=18"
21
24
  },
22
25
  "optionalDependencies": {
23
- "@lanekeep/darwin-arm64": "0.4.0",
24
- "@lanekeep/linux-arm64": "0.4.0",
25
- "@lanekeep/linux-x64": "0.4.0",
26
- "@lanekeep/win32-x64": "0.4.0"
26
+ "@lanekeep/darwin-arm64": "0.6.0",
27
+ "@lanekeep/linux-arm64": "0.6.0",
28
+ "@lanekeep/linux-x64": "0.6.0",
29
+ "@lanekeep/win32-x64": "0.6.0"
30
+ },
31
+ "main": "index.js",
32
+ "types": "index.d.ts",
33
+ "typesVersions": {
34
+ "*": {
35
+ "*": [
36
+ "builtin.d.ts"
37
+ ]
38
+ }
27
39
  }
28
40
  }