lanekeep 0.6.1 → 0.8.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.
Files changed (4) hide show
  1. package/README.md +113 -294
  2. package/builtin.d.ts +7 -15
  3. package/index.d.ts +112 -82
  4. package/package.json +5 -5
package/README.md CHANGED
@@ -12,220 +12,77 @@ lanekeep enforces the conventions that live in your team's heads and your review
12
12
  the ones a language model cannot infer from the code it is shown. Every rule is a codified answer
13
13
  to **"the agent keeps doing this wrong."**
14
14
 
15
- Checks **TypeScript, JavaScript, Python, Go and Rust**. Ships as a single static binary with no runtime
16
- dependency.
15
+ It ships as a single static binary with no runtime dependency.
17
16
 
18
17
  ---
19
18
 
20
- ## Quick start
19
+ ## Languages
21
20
 
22
- Sixty seconds, from nothing to a rule catching something.
21
+ Each guide covers installing lanekeep in that ecosystem, configuring it, the built-in rules that
22
+ apply, a worked custom rule, and the name-resolution behavior specific to that language.
23
23
 
24
- **1. Install** whichever fits the project you are adding it to:
24
+ | Language | Extensions | Install | Guide |
25
+ | --- | --- | --- | --- |
26
+ | Go | `.go` | `go get -tool github.com/fmsouza/lanekeep/cmd/lanekeep` | **[Go](https://github.com/fmsouza/lanekeep/wiki/Go)** |
27
+ | Python | `.py`, `.pyi` | `pip install lanekeep` | **[Python](https://github.com/fmsouza/lanekeep/wiki/Python)** |
28
+ | Rust | `.rs` | `cargo install lanekeep-cli` | **[Rust](https://github.com/fmsouza/lanekeep/wiki/Rust)** |
29
+ | TypeScript / JavaScript | `.ts`, `.mts`, `.cts`, `.tsx`, `.js`, `.mjs`, `.cjs`, `.jsx` | `npm install --save-dev lanekeep` | **[TypeScript and JavaScript](https://github.com/fmsouza/lanekeep/wiki/TypeScript-and-JavaScript)** |
25
30
 
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>
31
+ `brew install fmsouza/tap/lanekeep` works anywhere, as does a binary from the
32
+ [releases page](https://github.com/fmsouza/lanekeep/releases). Every channel delivers the same
33
+ build, so the bytes are identical whichever you pick.
43
34
 
44
- **2. Scaffold a config and a first rule:**
35
+ Whatever the project, the first two commands are the same:
45
36
 
46
37
  ```bash
47
- npx lanekeep init
38
+ lanekeep init # detects the project and writes a config plus a starter rule
39
+ lanekeep check
48
40
  ```
49
41
 
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.
59
-
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
- ```
72
-
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
- ---
42
+ New here? **[Getting Started](https://github.com/fmsouza/lanekeep/wiki/Getting-Started)** is about
43
+ a minute, end to end.
79
44
 
80
45
  ## What it is
81
46
 
82
47
  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**:
87
-
88
- ```ts
89
- import { defineRule } from 'lanekeep'
90
-
91
- export default defineRule({
92
- id: 'local/no-fmt-println',
93
- language: 'go',
94
- severity: 'error',
95
-
96
- card: {
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
- },
103
- },
104
-
105
- // Matched in Rust, at native speed. Your code runs only on matches.
106
- query: `
107
- (call_expression
108
- function: (selector_expression
109
- operand: (identifier) @pkg
110
- field: (field_identifier) @fn)) @call
111
- `,
112
-
113
- check(ctx, m) {
114
- if (ctx.text(m.pkg) !== 'fmt') return
115
- if (ctx.text(m.fn) !== 'Println') return
116
-
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
120
-
121
- ctx.report(m.call)
122
- },
123
- })
124
- ```
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
-
134
- `check` is ordinary TypeScript. Loop, accumulate state, build data structures, read other files,
135
- import shared helpers — there is no expressiveness ceiling and no DSL to learn beyond the query
136
- that gates it.
137
-
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.
140
-
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.
145
-
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.
148
-
149
- ## Supported languages
150
-
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.
153
-
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` |
160
-
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.
164
-
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.
169
-
170
- ## Configuration
171
-
172
- `lanekeep.json`, at the project root. `lanekeep init` writes one for you, matched to the
173
- project it finds.
174
-
175
- ```json
176
- {
177
- "$schema": "https://raw.githubusercontent.com/fmsouza/lanekeep/main/schema/lanekeep.schema.json",
178
-
179
- "include": ["**/*.go"],
180
- "exclude": ["**/*_test.go"],
181
-
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
- }
190
- ```
191
-
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.
195
-
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.
199
-
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.
203
-
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.
206
-
207
- <details>
208
- <summary>Configuring in TypeScript instead</summary>
209
-
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.
213
-
214
- ```ts
215
- import { defineConfig } from 'lanekeep'
216
- import noDefaultExport from 'lanekeep/no-default-export'
217
- import noDebugger from './lanekeep/rules/no-debugger'
218
-
219
- export default defineConfig({
220
- include: ['src/**/*.{ts,tsx}'],
221
- rules: [noDefaultExport, noDebugger],
222
- })
223
- ```
224
-
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.
227
-
228
- </details>
48
+ enforces *project-specific* conventions. The two barely overlap, and lanekeep replaces neither your
49
+ linter nor your formatter.
50
+
51
+ A rule is a **program**, not a configuration entry. It declares a
52
+ [tree-sitter query](https://tree-sitter.github.io/tree-sitter/using-parsers/queries/1-syntax.html)
53
+ that Rust matches at native speed, and a handler that runs only on matches — where it can loop,
54
+ accumulate state, read other files and ask where a name came from.
55
+
56
+ That matters because the conventions worth enforcing are the ones specific enough that nobody
57
+ else would ever write them, which is exactly the population a fixed vocabulary of predicates
58
+ fails. See **[Writing Rules](https://github.com/fmsouza/lanekeep/wiki/Writing-Rules)** for the
59
+ anatomy and the full host API, and each language guide for a worked example in that language.
60
+
61
+ Three things follow from who reads the output:
62
+
63
+ - **Every rule carries its own fix.** `message`, `remediation` and `examples` are mandatory
64
+ fields, not documentation — they are the card fed back to whoever has to act on the violation,
65
+ increasingly an agent.
66
+ - **Output is deterministic.** Violations are always sorted by `(ruleId, file, line, column)`, and
67
+ the sandbox withholds the clock and randomness, so two runs over identical input produce
68
+ byte-identical output. An agent reading it twice must not see reordering as change.
69
+ - **It runs in the inner loop.** Agents and developers invoke it after every edit, so a warm run
70
+ is measured in tens of milliseconds for a config whose rules are all TypeScript modules. A
71
+ rule that ships as a compiled component has to be loaded first, and the four TypeScript
72
+ built-ins share a 12.4 MiB one: a config naming all four of them costs **about 6.5 seconds on a
73
+ project's first run** and **about 0.2 seconds on every run after it** — the component is
74
+ deserialized once per run, not once per rule — and leaves 33 MiB in `.lanekeep`.
75
+ `lanekeep init` scaffolds one of those four, so that is what a new TypeScript project meets
76
+ first. [`docs/architecture.md`](docs/architecture.md) §15 has the table and what is owed.
77
+
78
+ **Rules are authored in TypeScript whatever language they check** that is the form to start
79
+ from, and it is the one most teams already have someone who writes. A rule may also be a
80
+ WebAssembly component, which is how eight of the ten built-ins ship — two written in Rust, two
81
+ written in Go, and four compiled ahead of time from the same TypeScript they were already
82
+ written in. Every form reaches the same host API and is held to the same limits, and a config
83
+ names a rule rather than its implementation. **Configuration is neither** — `lanekeep.json` is
84
+ plain data, so a Go, Python or Rust team never writes a `.ts` file except when authoring an
85
+ actual rule.
229
86
 
230
87
  ## Using it
231
88
 
@@ -238,97 +95,100 @@ lanekeep check --fix # apply the safe fixes, report what is left
238
95
  lanekeep check --profile # where the run spent its time, per rule
239
96
  lanekeep rules # what this project has configured
240
97
  lanekeep explain <rule-id> # one rule's card, without opening its source
98
+ lanekeep server # LSP for an editor, or --protocol mcp for an agent host
241
99
  ```
242
100
 
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.
246
-
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:
253
-
254
- ```ts
255
- // lanekeep-ignore-next-line lanekeep/no-default-export reason: legacy entry point
256
- export default parse
257
- ```
258
-
259
- Run `lanekeep check --report-unused-suppressions` to find the ones that no longer silence
260
- anything.
261
-
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.
266
-
267
101
  **Exit codes:** `0` clean, `1` violations found, `2` the checker could not run. A caller has to be
268
102
  able to tell "your code has problems" from "the tool is broken". `--warn-only` reports violations
269
103
  but exits `0`, for a phased rollout.
270
104
 
271
- ## In CI, editors and agents
105
+ **Output formats** via `--format`: `human` (default), `json` (versioned, stable schema), `sarif`
106
+ (GitHub code scanning), and `agent` — token-minimal, grouped by rule rather than by file. Diagnostics
107
+ always go to stderr, so piping into a parser works even when something fails.
272
108
 
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
278
- ```
279
-
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.
109
+ **Fixes** are applied only when the rule marked them behavior-preserving; anything else is shown
110
+ and never written. **Suppressions** carry a mandatory reason and an optional expiry, and a directive
111
+ that does not work says so rather than silently doing nothing.
282
112
 
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)**.
113
+ Configuration reference, CI recipes, editor setup and the MCP tool list are in the
114
+ [wiki](https://github.com/fmsouza/lanekeep/wiki).
285
115
 
286
116
  ## How it stays fast with programmable rules
287
117
 
288
118
  The usual problem with a native tool that runs JavaScript plugins is the boundary between them:
289
119
  dispatching into JS once per AST node means tens of thousands of crossings per file.
290
120
 
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.
121
+ lanekeep dispatches once per **query match** instead. The query runs in Rust across a single shared
122
+ parse; only matches reach your handler. That is typically two to three orders of magnitude fewer
123
+ crossings, and it is the reason a Rust engine still earns its place once rules are programs.
295
124
 
296
125
  ```
297
126
  discover paths (globs, gitignore-aware)
298
127
  └─> for each file, in parallel:
299
128
  cache key ──hit──> validate tracked deps ──> cached violations + facts
300
129
  └─miss─> path and raw-text gates reject before any parse
301
- └─> parse ─> match queries in Rust
302
- └─> invoke the TypeScript handler, per match only
130
+ └─> parse once ─> match queries in Rust
131
+ └─> invoke the handler, per match only
303
132
  └─> reduce phase: cross-file rules consume facts only, never parse trees
304
133
  └─> filter suppressions ─> sort ─> report
305
134
  ```
306
135
 
307
136
  A warm run with no changes executes no JavaScript at all — every file is a cache hit.
308
137
 
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
138
+ ## Platforms
314
139
 
315
140
  Prebuilt for macOS on Apple silicon, Linux on x86-64 and arm64, and Windows on x86-64. The Linux
316
141
  binaries are built against **glibc 2.17**, so they run on anything from RHEL 7 onwards.
317
142
 
143
+ **No runtime is required to run lanekeep.** Node, Python or Go is needed only to install it from
144
+ that ecosystem, where it picks which binary to fetch. Nothing is pulled in as a dependency any of
145
+ those ways.
146
+
318
147
  Intel macOS is not prebuilt — `cargo install lanekeep-cli` builds it from source, and both the npm
319
148
  launcher and the Homebrew formula say so rather than failing obscurely.
320
149
 
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.
150
+ ## Security
151
+
152
+ lanekeep is meant to run as a pre-commit hook and inside CI, which makes it a supply-chain target.
153
+ Rules are executable code, so the posture is about confinement rather than absence:
154
+
155
+ - **No ambient authority.** A TypeScript rule runs in an embedded QuickJS sandbox and a
156
+ WebAssembly rule under wasmtime; both reach exactly the host functions lanekeep exposes. `fs`,
157
+ `process`, `child_process`, network and dynamic import are not restricted — they do not exist in
158
+ the context. A component imports one interface and is refused at load if it imports another.
159
+ - **No network access.** Ever, in any mode, with no configuration that enables it.
160
+ - **Filesystem confinement.** Reads go through a tracked host call, confined to the project root.
161
+ Writes happen only under `--fix`, only to matched files, only within reported ranges.
162
+ - **Bounded execution.** A per-invocation timeout, a global run budget and a per-runtime memory
163
+ ceiling, none disableable — a rule that hangs a pre-commit hook is indistinguishable from a
164
+ broken tool. Breaching any of them cancels the run and exits `2`, rather than reporting a partial
165
+ result as a clean one.
166
+ - **Deterministic by construction.** The sandbox withholds the clock and randomness, so a rule
167
+ cannot introduce nondeterminism even by accident.
168
+
169
+ This bounds blast radius and makes third-party rule sets reviewable. It is not a boundary against
170
+ someone who can already commit to the repository being checked. To report a vulnerability, see
171
+ [`SECURITY.md`](SECURITY.md).
172
+
173
+ ## Project status
174
+
175
+ **Released and usable**, on every channel in the table above — one build feeding all of them.
176
+
177
+ It is **0.x**, and this repository treats that as semver does: a minor bump may break a public Rust
178
+ API. Rule authors are insulated from that — host API methods and the config shape are additive —
179
+ but pin a version if you embed the crates.
180
+
181
+ Known gaps, stated rather than implied:
324
182
 
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.
183
+ - **Two of the three performance budgets in [`docs/architecture.md`](docs/architecture.md)
184
+ §15 are not met.** The cold budget is; they are targets, and that section says by how much
185
+ and where the remaining time goes.
186
+ - **No type-aware analysis**, by design. Name resolution is syntactic — see §1 non-goals.
328
187
 
329
188
  ## Documentation
330
189
 
331
- **The [wiki](https://github.com/fmsouza/lanekeep/wiki) is the place to start** — it is task-shaped and organized by language.
190
+ **The [wiki](https://github.com/fmsouza/lanekeep/wiki) is the place to start** — it is task-shaped
191
+ and organized by language.
332
192
 
333
193
  | Page | Purpose |
334
194
  | --- | --- |
@@ -336,7 +196,6 @@ caches it. Set `LANEKEEP_BINARY` to an already-installed lanekeep and it fetches
336
196
  | [Configuration](https://github.com/fmsouza/lanekeep/wiki/Configuration) | `lanekeep.json`, every field |
337
197
  | [Writing Rules](https://github.com/fmsouza/lanekeep/wiki/Writing-Rules) | Rule anatomy and the full host API |
338
198
  | [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
199
 
341
200
  In-repo, versioned with the code:
342
201
 
@@ -352,46 +211,6 @@ In-repo, versioned with the code:
352
211
  | [`docs/releasing.md`](docs/releasing.md) | How a release is built, gated and published |
353
212
  | [`CHANGELOG.md`](CHANGELOG.md) | What changed, per release |
354
213
 
355
- ## Security
356
-
357
- lanekeep is meant to run as a pre-commit hook and inside CI, which makes it a supply-chain target.
358
- Rules are executable code, so the posture is about confinement rather than absence:
359
-
360
- - **No ambient authority.** Rules run in an embedded QuickJS sandbox and reach exactly the host
361
- functions lanekeep exposes. `fs`, `process`, `child_process`, network and dynamic import are not
362
- restricted — they do not exist in the context.
363
- - **No network access.** Ever, in any mode, with no configuration that enables it.
364
- - **Filesystem confinement.** Reads go through a tracked `ctx.readFile`, confined to the project
365
- root. Writes happen only under `--fix`, only to matched files, only within reported ranges.
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.
370
- - **Deterministic by construction.** The sandbox withholds the clock and randomness, so a rule
371
- cannot introduce nondeterminism even by accident.
372
-
373
- This bounds blast radius and makes third-party rule sets reviewable. It is not a boundary against
374
- someone who can already commit to the repository being checked. To report a vulnerability, see
375
- [`SECURITY.md`](SECURITY.md).
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
-
395
214
  ## Contributing
396
215
 
397
216
  Contributions are welcome, particularly new built-in rules and new host API surface. Start with
package/builtin.d.ts CHANGED
@@ -1,20 +1,12 @@
1
1
  /**
2
- * Types reached through the `typesVersions` mapping in `package.json`.
2
+ * Types for the importable built-in subpaths — the module built-ins, reached through the
3
+ * per-name `exports`/`typesVersions` entries `crates/lanekeep-package-gen` generates from
4
+ * `COMPONENT_RULES`. A component built-in has no entry there, so importing one is a compile
5
+ * error rather than a default export that lies.
3
6
  *
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.
7
+ * The default covers the two shapes an importable built-in can take, because which one it is
8
+ * cannot be known from the specifier: a rule taking options is a factory
9
+ * `noRestrictedImports({ ... })` and one taking none is the rule itself.
18
10
  */
19
11
  export * from './index'
20
12
 
package/index.d.ts CHANGED
@@ -1,26 +1,27 @@
1
1
  /**
2
2
  * Type definitions for authoring lanekeep rules.
3
3
  *
4
+ * **Generated from `crates/lanekeep-wasm/wit/world.wit` by `crates/lanekeep-types-gen`.** Do not
5
+ * edit by hand — run `just generate-index-dts` and commit the result.
6
+ *
4
7
  * These describe the host API a rule reaches inside lanekeep's sandbox. Nothing here runs in
5
8
  * Node: `defineRule` and `defineConfig` are identity functions whose only job is to give the
6
9
  * 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.
10
+ * The world is the single source of truth for every member the renderer emits straight from it.
11
+ * Two members deviate from the world on purpose, and both are QuickJS-shaped: `today` is omitted
12
+ * from `RuleContext` because QuickJS exposes it as a conditional property rather than a callable,
13
+ * a shape this renderer cannot state honestly from the world; and `facts` is added to
14
+ * `RuleContext` because QuickJS hands a per-file rule `facts` that the world declares only on
15
+ * `reduce-context`. Nothing else is added or omitted by hand.
12
16
  */
13
17
 
14
18
  /**
15
19
  * A node in the parse tree.
16
20
  *
17
21
  * 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.
22
+ * and the reason this is a branded type rather than `number` is that **the root node's handle
23
+ * is `0`** written as a plain number, `if (!node)` looks like a null check and silently
24
+ * discards the root. Compare against `undefined` explicitly.
24
25
  */
25
26
  export type Node = number & { readonly __lanekeepNode: unique symbol }
26
27
 
@@ -77,8 +78,27 @@ export interface RuleCard {
77
78
  }
78
79
  }
79
80
 
80
- /** Cheap rejections applied before a file is parsed. */
81
+ /** Cheap rejections applied before a file is read or parsed. */
81
82
  export interface Gates {
83
+ /**
84
+ * Glob patterns a file's path must match for the rule to consider it.
85
+ *
86
+ * The path is relative to the project root, and the pattern must match the whole path —
87
+ * anchored, not a substring search. Patterns use the `globset` dialect, matched
88
+ * case-sensitively: `*` matches any run of characters (including `/`), `?` any single
89
+ * character, `[ab]`/`[!ab]` character classes and `{a,b}` alternates work, and `**`
90
+ * recurses directories — `src/**` admits everything under `src`, and `**` in front
91
+ * of `*.test.ts` admits a test file at any depth.
92
+ */
93
+ pathMatches?: string[]
94
+ /**
95
+ * Glob patterns that skip a file — a path matching any of these is never parsed. Checked
96
+ * before `pathMatches` and winning over it: a path a `pathMatches` pattern would have
97
+ * admitted is still skipped when a `pathNotMatches` pattern matches it.
98
+ *
99
+ * Same dialect and anchoring as `pathMatches`.
100
+ */
101
+ pathNotMatches?: string[]
82
102
  /**
83
103
  * Literal substrings a file's raw bytes must contain. A file missing any one of them is
84
104
  * never parsed.
@@ -89,6 +109,12 @@ export interface Gates {
89
109
  * form; omit the gate when no single substring covers every case.
90
110
  */
91
111
  fileContains?: string[]
112
+ /**
113
+ * Literal substrings that skip a file — a file whose raw bytes contain **any** of them is
114
+ * never parsed. The mirror image of `fileContains`'s *and*: where that gate requires every
115
+ * listed substring, this one rejects on the first that is present.
116
+ */
117
+ fileNotContains?: string[]
92
118
  }
93
119
 
94
120
  /** A replacement a rule offers for a violation. */
@@ -134,74 +160,65 @@ export interface EmittedFact extends Fact {
134
160
  file: string
135
161
  }
136
162
 
137
- /** What a rule's `check` handler reaches. */
163
+ /**
164
+ * A node's location: the file, line and column `ctx.loc` returns.
165
+ *
166
+ * `line` and `column` are required here, unlike on `ReduceLocation`: `ctx.loc` either
167
+ * resolves the node and returns all three together, or the node does not resolve and the
168
+ * call returns `undefined` entirely — there is no partial state to leave room for.
169
+ */
170
+ export interface NodeLocation {
171
+ /** Path relative to the project root. */
172
+ file: string
173
+ /** One-based. */
174
+ line: number
175
+ /** One-based. */
176
+ column: number
177
+ }
178
+
179
+ /**
180
+ * A subtree's structural fingerprint: identifiers and literal values erased.
181
+ *
182
+ * Computed host-side in one walk, so a rule does not pay a per-node boundary crossing to
183
+ * inspect a tree's shape. Two functions differing only in identifier names, literal values
184
+ * or comments hash identically; differing in an operator or a statement, differently. A
185
+ * dead handle yields `undefined`, like `kind` and `loc`.
186
+ */
187
+ export interface StructureFingerprint {
188
+ /** blake3 of the normalized fold, lowercase hex. */
189
+ hash: string
190
+ /** How many nodes the fold covered — the thresholding input. */
191
+ nodes: number
192
+ }
193
+
194
+ /** A rule's RuleContext surface. */
138
195
  export interface RuleContext {
139
- /** Path of the file being checked, relative to the project root. */
140
196
  readonly filePath: string
141
- /** The whole file, as text. */
142
197
  readonly fileText: string
143
- /** The tree's root node. Its handle is `0` — see {@link Node}. */
144
198
  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
- */
199
+ kind(n: Node): string | undefined
200
+ text(n: Node): string | undefined
201
+ isNamed(n: Node): boolean
202
+ line(n: Node): number | undefined
203
+ column(n: Node): number | undefined
204
+ parent(n: Node): Node | undefined
205
+ children(n: Node): Node[]
206
+ namedChildren(n: Node): Node[]
207
+ ancestors(n: Node): Node[]
208
+ structureFingerprint(n: Node): StructureFingerprint | undefined
209
+ resolvesToImport(n: Node, module: string, name?: string): boolean
210
+ isImportedFrom(n: Node, pattern: string): boolean
211
+ bindingKind(n: Node): BindingKind | undefined
212
+ isShadowed(n: Node): boolean
213
+ querySubtree(n: Node, query: string): Match[]
214
+ closestAncestor(n: Node, query: string): Match | undefined
194
215
  readFile(path: string): string | undefined
195
- /** Whether a file exists, tracked the same way. */
196
216
  fileExists(path: string): boolean
197
-
198
- /** Emit a fact for the reduce phase. */
199
217
  emitFact(fact: Fact): void
218
+ loc(n: Node): NodeLocation | undefined
219
+ report(at: Node, message?: string | ReportOptions): void
200
220
  /** Facts emitted so far, optionally filtered by `kind`. */
201
221
  facts(kind?: string): EmittedFact[]
202
-
203
- /** Report a violation at a node. */
204
- report(at: Node, message?: string | ReportOptions): void
205
222
  }
206
223
 
207
224
  /** A violation the reduce phase reports, which has no node to point at. */
@@ -214,19 +231,10 @@ export interface ReduceLocation {
214
231
  column?: number
215
232
  }
216
233
 
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
- */
234
+ /** A rule's ReduceContext surface. */
224
235
  export interface ReduceContext {
225
- /** Every file the run checked, relative to the project root. */
226
236
  readonly files: string[]
227
- /** Facts from every file, optionally filtered by `kind`. */
228
237
  facts(kind?: string): EmittedFact[]
229
- /** Report a violation against a file. */
230
238
  report(at: ReduceLocation, message?: string | ReportOptions): void
231
239
  }
232
240
 
@@ -260,8 +268,21 @@ export interface Rule {
260
268
  * Rust matches it across a single shared parse and only matches reach `check`, which is
261
269
  * what keeps a JavaScript rule affordable. Write the narrowest query that captures what
262
270
  * you need; `check` then only refines.
271
+ *
272
+ * A single string applies to every declared language. An object maps each declared
273
+ * language to its own query — required when the grammars do not share node vocabulary
274
+ * (Python spells a call `call`, the other supported grammars say `call_expression`).
275
+ * Every declared language must have an entry and every entry must name a declared
276
+ * language; a mismatch is a config-load error naming the language.
277
+ *
278
+ * Text predicates filter matches in Rust before the handler, so a predicate can only
279
+ * narrow, never widen, what `check` sees: `#eq?`, `#not-eq?`, `#match?`, `#not-match?`,
280
+ * `#any-of?` and `#not-any-of?` are supported (plus the `any-` forms of `eq?`/`match?`).
281
+ * `#match?`/`#not-match?` run on the `regex` crate, which is deterministic and supports
282
+ * no backreferences or lookaround. `#is?`, `#is-not?`, `#set!`, or an operator the
283
+ * binding does not know is refused at compile time.
263
284
  */
264
- query: string
285
+ query: string | Partial<Record<LanguageId, string>>
265
286
  /** A per-invocation budget overriding the default, in milliseconds. */
266
287
  timeout?: number
267
288
  /** Called once per query match. */
@@ -287,6 +308,15 @@ export interface Config {
287
308
  /** Wall-clock, for the whole run. */
288
309
  global?: number
289
310
  }
311
+ /** Policy for suppression directives. All off by default. */
312
+ suppressions?: {
313
+ /** A valid directive with no `expires:` is reported. */
314
+ requireExpiry?: boolean
315
+ /** An expiry more than this many days after today is reported. */
316
+ maxExpiryDays?: number
317
+ /** Any whole-file directive is reported. */
318
+ forbidFileScope?: boolean
319
+ }
290
320
  /** The rules to run, in order. */
291
321
  rules: Rule[]
292
322
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lanekeep",
3
- "version": "0.6.1",
3
+ "version": "0.8.0",
4
4
  "description": "Deterministic, AST-based architectural conformance checking",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "repository": {
@@ -23,10 +23,10 @@
23
23
  "node": ">=18"
24
24
  },
25
25
  "optionalDependencies": {
26
- "@lanekeep/darwin-arm64": "0.6.1",
27
- "@lanekeep/linux-arm64": "0.6.1",
28
- "@lanekeep/linux-x64": "0.6.1",
29
- "@lanekeep/win32-x64": "0.6.1"
26
+ "@lanekeep/darwin-arm64": "0.8.0",
27
+ "@lanekeep/linux-arm64": "0.8.0",
28
+ "@lanekeep/linux-x64": "0.8.0",
29
+ "@lanekeep/win32-x64": "0.8.0"
30
30
  },
31
31
  "main": "index.js",
32
32
  "types": "index.d.ts",