lanekeep 0.1.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 +249 -0
- package/bin/lanekeep +48 -0
- package/package.json +28 -0
- package/resolve.js +72 -0
package/README.md
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# lanekeep
|
|
2
|
+
|
|
3
|
+
**Deterministic, AST-based architectural conformance checking for AI-generated and human-written code.**
|
|
4
|
+
|
|
5
|
+
[](#license)
|
|
6
|
+
|
|
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.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## What it is
|
|
14
|
+
|
|
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.
|
|
18
|
+
|
|
19
|
+
Every rule is a codified answer to **"the agent keeps doing this wrong."**
|
|
20
|
+
|
|
21
|
+
Rules are TypeScript programs, written in the same language as the code they inspect:
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { defineRule } from 'lanekeep'
|
|
25
|
+
|
|
26
|
+
export default defineRule({
|
|
27
|
+
id: 'local/no-numeric-sizes',
|
|
28
|
+
severity: 'error',
|
|
29
|
+
|
|
30
|
+
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' },
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
// Matched in Rust, at native speed. Your code runs only on matches.
|
|
37
|
+
query: `
|
|
38
|
+
(pair
|
|
39
|
+
key: (property_identifier) @prop
|
|
40
|
+
value: [(number) (unary_expression operand: (number))] @value) @match
|
|
41
|
+
`,
|
|
42
|
+
|
|
43
|
+
check(ctx, m) {
|
|
44
|
+
if (!/^(padding|margin|gap|borderRadius)/.test(ctx.text(m.prop))) return
|
|
45
|
+
if (Number(ctx.text(m.value)) === 0) return
|
|
46
|
+
|
|
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
|
|
50
|
+
|
|
51
|
+
ctx.report(m.match)
|
|
52
|
+
},
|
|
53
|
+
})
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`check` is ordinary TypeScript. Loop, accumulate state, build data structures, read other files,
|
|
57
|
+
import shared helpers — there is no expressiveness ceiling and no DSL to learn beyond the query
|
|
58
|
+
that gates it.
|
|
59
|
+
|
|
60
|
+
## Why it exists
|
|
61
|
+
|
|
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.
|
|
65
|
+
|
|
66
|
+
That makes the design constraints unusual for a static analyzer:
|
|
67
|
+
|
|
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.
|
|
74
|
+
|
|
75
|
+
## How it stays fast with programmable rules
|
|
76
|
+
|
|
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.
|
|
79
|
+
|
|
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.
|
|
84
|
+
|
|
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
|
+
```
|
|
95
|
+
|
|
96
|
+
A warm run with no changes executes no JavaScript at all — every file is a cache hit.
|
|
97
|
+
|
|
98
|
+
## Installation
|
|
99
|
+
|
|
100
|
+
Not yet published. The distribution is built and tested — one npm package per platform plus a
|
|
101
|
+
launcher that resolves the right one, and every crate publishable to crates.io — but no
|
|
102
|
+
version has been released yet.
|
|
103
|
+
|
|
104
|
+
When it ships:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
npm install --save-dev lanekeep # or: cargo install lanekeep-cli
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
A single static binary with the JavaScript engine compiled in. **Node.js is not required to
|
|
111
|
+
run lanekeep**, even though rules are written in TypeScript — it is required only to install
|
|
112
|
+
it from npm, where it picks which binary to fetch.
|
|
113
|
+
|
|
114
|
+
Prebuilt for macOS on Apple silicon, Linux on x86-64 and arm64, and Windows on x86-64.
|
|
115
|
+
Intel macOS is not prebuilt — `cargo install lanekeep-cli` builds it from source, and the
|
|
116
|
+
npm launcher says so rather than failing obscurely.
|
|
117
|
+
|
|
118
|
+
See [`docs/releasing.md`](docs/releasing.md) for how a release is cut.
|
|
119
|
+
|
|
120
|
+
## What it looks like
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
$ lanekeep check
|
|
124
|
+
src/also.ts:2:1 error [lanekeep/no-default-export] default export
|
|
125
|
+
→ use a named export, so the symbol has one name every importer must use
|
|
126
|
+
src/bad.ts:2:1 error [lanekeep/no-default-export] default export
|
|
127
|
+
→ use a named export, so the symbol has one name every importer must use
|
|
128
|
+
|
|
129
|
+
✖ 2 error(s) across 2 file(s) checked
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Rules may offer a fix, applied with `--fix`:
|
|
133
|
+
|
|
134
|
+
```
|
|
135
|
+
$ lanekeep check --fix
|
|
136
|
+
fixed 2 violation(s) in 2 file(s)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Only fixes a rule marked as behavior-preserving are applied. Anything else is a suggestion —
|
|
140
|
+
shown, never written — because the cautious mistake costs a manual edit and the other one
|
|
141
|
+
rewrites your code silently.
|
|
142
|
+
|
|
143
|
+
Suppressions carry a mandatory reason and an optional expiry, and a directive that does not
|
|
144
|
+
work says so — a missing reason, a bare rule id, or an unreadable date is reported rather
|
|
145
|
+
than silently doing nothing:
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
// lanekeep-ignore-next-line lanekeep/no-default-export reason: legacy entry point
|
|
149
|
+
export default parse
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
```
|
|
153
|
+
$ lanekeep check --report-unused-suppressions
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
To start from nothing:
|
|
157
|
+
|
|
158
|
+
```
|
|
159
|
+
$ lanekeep init # a config plus a first rule, both runnable
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
To find out where a run spent its time — the split says whether the query or the code is
|
|
163
|
+
the problem:
|
|
164
|
+
|
|
165
|
+
```
|
|
166
|
+
$ lanekeep check --profile
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
To find out what a rule wants without opening its source:
|
|
170
|
+
|
|
171
|
+
```
|
|
172
|
+
$ lanekeep explain lanekeep/no-default-export
|
|
173
|
+
$ lanekeep rules --json
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
For fast feedback on what you touched:
|
|
177
|
+
|
|
178
|
+
```
|
|
179
|
+
$ lanekeep check --staged # what is about to be committed
|
|
180
|
+
$ lanekeep check --since main # what changed against a ref
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Both are intersected with the config's `include`/`exclude`, and both skip cross-file rules —
|
|
184
|
+
a whole-corpus rule over a subset gives a wrong answer, not a smaller one, so they are
|
|
185
|
+
skipped and named on stderr rather than quietly producing one.
|
|
186
|
+
|
|
187
|
+
Exit `0` when clean, `1` when violations are found, `2` when the checker could not run —
|
|
188
|
+
a caller has to be able to tell "your code has problems" from "the tool is broken".
|
|
189
|
+
Four output formats: `human` (default), `json` (versioned, stable schema), `sarif` (GitHub
|
|
190
|
+
code scanning), and `agent` — token-minimal, grouped by rule rather than by file, with each
|
|
191
|
+
rule's card stated once instead of once per violation. Diagnostics always go to stderr, so
|
|
192
|
+
piping into a parser works even when something fails.
|
|
193
|
+
|
|
194
|
+
## Documentation
|
|
195
|
+
|
|
196
|
+
| Document | Purpose |
|
|
197
|
+
| --- | --- |
|
|
198
|
+
| [`docs/architecture.md`](docs/architecture.md) | The full design: execution model, host API, cache, milestones |
|
|
199
|
+
| [`docs/built-in-rules.md`](docs/built-in-rules.md) | The rules lanekeep ships with, and their options |
|
|
200
|
+
| [`docs/cross-file-rules.md`](docs/cross-file-rules.md) | Writing a rule that needs a whole-corpus view |
|
|
201
|
+
| [`AGENTS.md`](AGENTS.md) | How to work in this repository — for coding agents and humans alike |
|
|
202
|
+
| [`CONTRIBUTING.md`](CONTRIBUTING.md) | Setup, commands, and the pull request process |
|
|
203
|
+
| [`SECURITY.md`](SECURITY.md) | Threat model and how to report a vulnerability |
|
|
204
|
+
| [`docs/releasing.md`](docs/releasing.md) | How a release is built, gated and published |
|
|
205
|
+
|
|
206
|
+
## Security
|
|
207
|
+
|
|
208
|
+
lanekeep is meant to run as a pre-commit hook and inside CI, which makes it a supply-chain target.
|
|
209
|
+
Rules are executable code, so the posture is about confinement rather than absence:
|
|
210
|
+
|
|
211
|
+
- **No ambient authority.** Rules run in an embedded QuickJS sandbox and reach exactly the host
|
|
212
|
+
functions lanekeep exposes. `fs`, `process`, `child_process`, network and dynamic import are not
|
|
213
|
+
restricted — they do not exist in the context.
|
|
214
|
+
- **No network access.** Ever, in any mode, with no configuration that enables it.
|
|
215
|
+
- **Filesystem confinement.** Reads go through a tracked `ctx.readFile`, confined to the project
|
|
216
|
+
root. Writes happen only under `--fix`, only to matched files, only within reported ranges.
|
|
217
|
+
- **Bounded execution.** A per-invocation timeout, a 15-second global run budget and a per-runtime
|
|
218
|
+
memory ceiling, none disableable — a rule that hangs a pre-commit hook is indistinguishable from
|
|
219
|
+
a broken tool. Breaching any of them cancels the run and exits `2`, rather than reporting a
|
|
220
|
+
partial result as a clean one.
|
|
221
|
+
- **Deterministic by construction.** The sandbox withholds the clock and randomness, so a rule
|
|
222
|
+
cannot introduce nondeterminism even by accident.
|
|
223
|
+
|
|
224
|
+
This bounds blast radius and makes third-party rule sets reviewable. It is not a boundary against
|
|
225
|
+
someone who can already commit to the repository being checked. To report a vulnerability, see
|
|
226
|
+
[`SECURITY.md`](SECURITY.md).
|
|
227
|
+
|
|
228
|
+
## Contributing
|
|
229
|
+
|
|
230
|
+
Contributions are welcome, particularly new built-in rules and new host API surface. Start
|
|
231
|
+
with [`CONTRIBUTING.md`](CONTRIBUTING.md) — `./scripts/setup-dev.sh` installs everything and
|
|
232
|
+
wires the git hooks.
|
|
233
|
+
|
|
234
|
+
All work ships as squashed pull requests with [Conventional Commits](https://www.conventionalcommits.org/)
|
|
235
|
+
titles. `main` is protected and takes no direct pushes.
|
|
236
|
+
|
|
237
|
+
## License
|
|
238
|
+
|
|
239
|
+
Licensed under either of
|
|
240
|
+
|
|
241
|
+
- Apache License, Version 2.0 ([`LICENSE-APACHE`](LICENSE-APACHE) or
|
|
242
|
+
<http://www.apache.org/licenses/LICENSE-2.0>)
|
|
243
|
+
- MIT License ([`LICENSE-MIT`](LICENSE-MIT) or <http://opensource.org/licenses/MIT>)
|
|
244
|
+
|
|
245
|
+
at your option.
|
|
246
|
+
|
|
247
|
+
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in
|
|
248
|
+
this work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without
|
|
249
|
+
any additional terms or conditions.
|
package/bin/lanekeep
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Hand control to the platform binary.
|
|
4
|
+
*
|
|
5
|
+
* lanekeep's exit code is load-bearing — 0 clean, 1 violations, 2 could not run — so this
|
|
6
|
+
* wrapper's whole job is to pass it through unchanged, along with stdio and any signal that
|
|
7
|
+
* killed the child.
|
|
8
|
+
*
|
|
9
|
+
* A `spawnSync` with inherited stdio rather than an `execve`: replacing the process would be
|
|
10
|
+
* marginally cleaner on Unix, Node has no way to do it without a native dependency, and a
|
|
11
|
+
* native dependency in the package whose entire point is not needing one is the wrong trade.
|
|
12
|
+
* The cost is one idle Node process for the duration of a run.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const { spawnSync } = require('node:child_process')
|
|
16
|
+
|
|
17
|
+
const { resolveBinary } = require('../resolve.js')
|
|
18
|
+
|
|
19
|
+
let binary
|
|
20
|
+
try {
|
|
21
|
+
binary = resolveBinary()
|
|
22
|
+
} catch (error) {
|
|
23
|
+
process.stderr.write(`${error.message}\n`)
|
|
24
|
+
// 2, not 1: lanekeep could not run, which is not the same as finding violations. A
|
|
25
|
+
// pre-commit hook that read a broken install as "your code has problems" would be worse
|
|
26
|
+
// than useless — it would block on something the author cannot fix by editing code.
|
|
27
|
+
process.exit(2)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const result = spawnSync(binary, process.argv.slice(2), {
|
|
31
|
+
stdio: 'inherit',
|
|
32
|
+
windowsHide: true,
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
if (result.error) {
|
|
36
|
+
process.stderr.write(`lanekeep could not start: ${result.error.message}\n`)
|
|
37
|
+
process.exit(2)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (result.signal) {
|
|
41
|
+
// Re-raised rather than turned into an exit code, so a caller that distinguishes a killed
|
|
42
|
+
// process from a failing one still can. Windows reports none, so this never fires there.
|
|
43
|
+
process.kill(process.pid, result.signal)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// `null` means the child died from a signal the platform did not name. The run did not
|
|
47
|
+
// finish, so 2 is the honest answer.
|
|
48
|
+
process.exit(result.status ?? 2)
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "lanekeep",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Deterministic, AST-based architectural conformance checking",
|
|
5
|
+
"license": "MIT OR Apache-2.0",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/fmsouza/lanekeep.git"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/fmsouza/lanekeep#readme",
|
|
11
|
+
"bin": {
|
|
12
|
+
"lanekeep": "bin/lanekeep"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"bin/lanekeep",
|
|
16
|
+
"resolve.js",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=18"
|
|
21
|
+
},
|
|
22
|
+
"optionalDependencies": {
|
|
23
|
+
"@lanekeep/darwin-arm64": "0.1.0",
|
|
24
|
+
"@lanekeep/linux-arm64": "0.1.0",
|
|
25
|
+
"@lanekeep/linux-x64": "0.1.0",
|
|
26
|
+
"@lanekeep/win32-x64": "0.1.0"
|
|
27
|
+
}
|
|
28
|
+
}
|
package/resolve.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Find the platform binary this machine should run.
|
|
3
|
+
*
|
|
4
|
+
* lanekeep ships one npm package per platform and a launcher — this one — that depends on
|
|
5
|
+
* all of them as `optionalDependencies`. npm installs only the one whose `os` and `cpu` match,
|
|
6
|
+
* so a developer downloads one binary rather than five.
|
|
7
|
+
*
|
|
8
|
+
* **Node is not required to run lanekeep.** It is required only to install it this way. The
|
|
9
|
+
* binary has the JavaScript engine compiled in; this file exists to pick which binary.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const { existsSync } = require('node:fs')
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The platform packages, keyed by what Node reports.
|
|
16
|
+
*
|
|
17
|
+
* Written out rather than composed from `${platform}-${arch}`, so a platform lanekeep does
|
|
18
|
+
* not publish for produces a message naming what is available instead of a confusing
|
|
19
|
+
* "cannot find module @lanekeep/sunos-sparc".
|
|
20
|
+
*/
|
|
21
|
+
const PACKAGES = {
|
|
22
|
+
'darwin-arm64': '@lanekeep/darwin-arm64',
|
|
23
|
+
'linux-arm64': '@lanekeep/linux-arm64',
|
|
24
|
+
'linux-x64': '@lanekeep/linux-x64',
|
|
25
|
+
'win32-x64': '@lanekeep/win32-x64',
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Where the binary sits inside a platform package. */
|
|
29
|
+
function binaryName(platform) {
|
|
30
|
+
return platform === 'win32' ? 'lanekeep.exe' : 'lanekeep'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The path to this machine's lanekeep binary.
|
|
35
|
+
*
|
|
36
|
+
* @throws if this platform has no package, or the package is missing.
|
|
37
|
+
*/
|
|
38
|
+
function resolveBinary(platform = process.platform, arch = process.arch) {
|
|
39
|
+
const key = `${platform}-${arch}`
|
|
40
|
+
const pkg = PACKAGES[key]
|
|
41
|
+
|
|
42
|
+
if (!pkg) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`lanekeep does not ship a binary for ${key}\n` +
|
|
45
|
+
` available: ${Object.keys(PACKAGES).sort().join(', ')}\n` +
|
|
46
|
+
` build from source with: cargo install lanekeep-cli`,
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let binary
|
|
51
|
+
try {
|
|
52
|
+
// Resolved through Node rather than joined by hand, so it works with npm, pnpm's
|
|
53
|
+
// symlinked store, and Yarn's zero-install layout — three arrangements that agree on
|
|
54
|
+
// `require.resolve` and on nothing else.
|
|
55
|
+
binary = require.resolve(`${pkg}/bin/${binaryName(platform)}`)
|
|
56
|
+
} catch {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`lanekeep's binary for ${key} is not installed\n` +
|
|
59
|
+
` expected the optional dependency ${pkg}\n` +
|
|
60
|
+
` this usually means the install ran with --no-optional, or on a different platform\n` +
|
|
61
|
+
` reinstall with: npm install lanekeep`,
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!existsSync(binary)) {
|
|
66
|
+
throw new Error(`lanekeep's binary for ${key} is missing at ${binary}`)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return binary
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = { PACKAGES, binaryName, resolveBinary }
|