leanest 0.1.1 → 0.2.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 +21 -10
- package/package.json +4 -2
- package/src/cli.ts +5 -5
- package/src/index.ts +3 -2
- package/src/{sieve.ts → leanest.ts} +20 -36
- package/src/jev-client.ts +0 -78
package/README.md
CHANGED
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
[](https://www.npmjs.com/package/leanest)
|
|
5
5
|
[](https://github.com/baronunread/leanest/actions)
|
|
6
6
|
|
|
7
|
+
**[leanest.pages.dev](https://leanest.pages.dev/)**
|
|
8
|
+
|
|
7
9
|
> Leanest does not predict which tests will fail. It determines which tests are safe enough not to run.
|
|
8
10
|
|
|
9
|
-
Local-first test selection using [Jev](https://typesafe.ai)
|
|
11
|
+
Local-first test selection using semantic judgments (classifier.dev by default, or [Jev](https://typesafe.ai)/Laya). Leanest sits in front of your existing test runner and runs only the tests that matter for a given code change. Everything else it skips, on purpose, out loud.
|
|
10
12
|
|
|
11
13
|
---
|
|
12
14
|
|
|
@@ -18,11 +20,7 @@ Requires [Bun](https://bun.sh): the CLI runs on it directly, no build step.
|
|
|
18
20
|
bun add -D leanest
|
|
19
21
|
```
|
|
20
22
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
```bash
|
|
24
|
-
export TYPESAFE_API_KEY="..."
|
|
25
|
-
```
|
|
23
|
+
Works with no setup: leanest defaults to classifier.dev, a free, no-auth judge. Switch to Jev if you want it by exporting `TYPESAFE_API_KEY` and setting `LEANEST_PROVIDER=jev` (see [Judge provider](#judge-provider)).
|
|
26
24
|
|
|
27
25
|
## Quick Start
|
|
28
26
|
|
|
@@ -153,7 +151,21 @@ Leanest loads `.env` for local convenience. The API key is never persisted or lo
|
|
|
153
151
|
TYPESAFE_API_KEY=...
|
|
154
152
|
```
|
|
155
153
|
|
|
156
|
-
|
|
154
|
+
Framework choice, base ref, and target directory are all CLI flags (`--base`, `--dir`), so there's nothing else to set up per project.
|
|
155
|
+
|
|
156
|
+
### Judge provider
|
|
157
|
+
|
|
158
|
+
Leanest's selection judgment is pluggable. Pick a provider with `LEANEST_PROVIDER`:
|
|
159
|
+
|
|
160
|
+
| Provider | How | API key needed |
|
|
161
|
+
| --------------------------- | ------------------------------------------------ | --------------- |
|
|
162
|
+
| `classifier-dev` (default) | classifier.dev, a free zero-shot classifier | none |
|
|
163
|
+
| `jev` | TypeSafe's Jev, over HTTP | `TYPESAFE_API_KEY` |
|
|
164
|
+
| `laya` | Laya, self-hosted, runs in-process via ONNX Runtime (`bun add @receptron/laya`) | none |
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
LEANEST_PROVIDER=jev npx leanest playwright
|
|
168
|
+
```
|
|
157
169
|
|
|
158
170
|
## CI Integration
|
|
159
171
|
|
|
@@ -167,10 +179,9 @@ That's the only configuration knob today. Framework choice, base ref, and target
|
|
|
167
179
|
- uses: baronunread/leanest@v1
|
|
168
180
|
with:
|
|
169
181
|
framework: playwright
|
|
170
|
-
typesafe-api-key: ${{ secrets.TYPESAFE_API_KEY }}
|
|
171
182
|
```
|
|
172
183
|
|
|
173
|
-
This installs Bun, installs `leanest`, and replaces your existing "run e2e tests" step: same reporter output, same exit code, just fewer tests executed.
|
|
184
|
+
This installs Bun, installs `leanest`, and replaces your existing "run e2e tests" step: same reporter output, same exit code, just fewer tests executed. No secret required — the default `classifier-dev` provider needs no API key, which also means forked-repo PRs can use it without access to your repo's secrets. Pass `provider: jev` and `typesafe-api-key: ${{ secrets.TYPESAFE_API_KEY }}` to use Jev instead.
|
|
174
185
|
|
|
175
186
|
### Any other CI
|
|
176
187
|
|
|
@@ -191,7 +202,7 @@ bun test # just the test suite
|
|
|
191
202
|
|
|
192
203
|
## Contributing
|
|
193
204
|
|
|
194
|
-
See [
|
|
205
|
+
See [LEANEST_SPEC.md](./LEANEST_SPEC.md) for the design rationale behind the selection policy.
|
|
195
206
|
|
|
196
207
|
## License
|
|
197
208
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leanest",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Local-first test selector using
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Local-first test selector using semantic judgments (classifier.dev, Jev, or Laya) to determine which tests are affected by a code change",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
7
7
|
"module": "./src/index.ts",
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
}
|
|
17
17
|
},
|
|
18
18
|
"files": ["src/**", "!src/**/*.test.ts"],
|
|
19
|
+
"workspaces": ["packages/*"],
|
|
19
20
|
"scripts": {
|
|
20
21
|
"lint": "bunx oxlint",
|
|
21
22
|
"format": "bunx oxfmt",
|
|
@@ -38,6 +39,7 @@
|
|
|
38
39
|
"homepage": "https://github.com/baronunread/leanest#readme",
|
|
39
40
|
"packageManager": "bun@1.4.1",
|
|
40
41
|
"dependencies": {
|
|
42
|
+
"@leanest/judge": "workspace:*",
|
|
41
43
|
"dotenv": "^17.4.2"
|
|
42
44
|
},
|
|
43
45
|
"devDependencies": {
|
package/src/cli.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { Leanest } from "./leanest.js";
|
|
4
4
|
import { SelectionPolicy } from "./selection-policy.js";
|
|
5
5
|
import { runTests } from "./runner.js";
|
|
6
6
|
|
|
@@ -29,7 +29,7 @@ async function main(): Promise<number> {
|
|
|
29
29
|
const cwd = flags.dir ?? ".";
|
|
30
30
|
const base = flags.base;
|
|
31
31
|
|
|
32
|
-
const
|
|
32
|
+
const leanest = new Leanest(cwd, base);
|
|
33
33
|
|
|
34
34
|
if (command === "help" || !command) {
|
|
35
35
|
printHelp();
|
|
@@ -38,12 +38,12 @@ async function main(): Promise<number> {
|
|
|
38
38
|
|
|
39
39
|
switch (command) {
|
|
40
40
|
case "inspect": {
|
|
41
|
-
const result = await
|
|
41
|
+
const result = await leanest.inspect(framework);
|
|
42
42
|
printInspect(result);
|
|
43
43
|
return 0;
|
|
44
44
|
}
|
|
45
45
|
case "select": {
|
|
46
|
-
const result = await
|
|
46
|
+
const result = await leanest.select(framework, changed);
|
|
47
47
|
if (json) {
|
|
48
48
|
console.log(JSON.stringify(result, null, 2));
|
|
49
49
|
} else {
|
|
@@ -53,7 +53,7 @@ async function main(): Promise<number> {
|
|
|
53
53
|
}
|
|
54
54
|
case "playwright":
|
|
55
55
|
case "vitest": {
|
|
56
|
-
const result = await
|
|
56
|
+
const result = await leanest.select(command, changed);
|
|
57
57
|
if (result.status === "error") {
|
|
58
58
|
console.error(`⚠ Jev unavailable (${result.error}), running the full suite.`);
|
|
59
59
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export { runLeanest, Leanest } from "./leanest.js";
|
|
2
2
|
export { SelectionPolicy } from "./selection-policy.js";
|
|
3
3
|
export { ChangeResolver } from "./git-diff.js";
|
|
4
4
|
export { ContextBuilder } from "./context-builder.js";
|
|
5
5
|
export { TestDiscovery } from "./test-discovery.js";
|
|
6
|
-
export {
|
|
6
|
+
export { getProvider } from "@leanest/judge";
|
|
7
|
+
export type { JudgeProvider, JudgeState, JudgeQuestion, JudgeAnswer } from "@leanest/judge";
|
|
7
8
|
export type {
|
|
8
9
|
TestCase,
|
|
9
10
|
ChangeContext,
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { getProvider, type JudgeProvider, type JudgeQuestion } from "@leanest/judge";
|
|
2
2
|
import { ChangeResolver } from "./git-diff.js";
|
|
3
3
|
import { TestDiscovery } from "./test-discovery.js";
|
|
4
4
|
import { ContextBuilder } from "./context-builder.js";
|
|
5
5
|
import { SelectionPolicy } from "./selection-policy.js";
|
|
6
6
|
import type { TestCase, SelectionResult, PipelineResult } from "./types.js";
|
|
7
7
|
|
|
8
|
-
export class
|
|
9
|
-
private
|
|
8
|
+
export class Leanest {
|
|
9
|
+
private judge: JudgeProvider;
|
|
10
10
|
private git: ChangeResolver;
|
|
11
11
|
private discovery: TestDiscovery;
|
|
12
12
|
private context: ContextBuilder;
|
|
@@ -16,7 +16,7 @@ export class Sieve {
|
|
|
16
16
|
|
|
17
17
|
constructor(cwd?: string, baseRef?: string) {
|
|
18
18
|
this.cwd = cwd ?? ".";
|
|
19
|
-
this.
|
|
19
|
+
this.judge = getProvider();
|
|
20
20
|
this.git = new ChangeResolver(baseRef, this.cwd);
|
|
21
21
|
this.discovery = new TestDiscovery();
|
|
22
22
|
this.context = new ContextBuilder();
|
|
@@ -43,9 +43,9 @@ export class Sieve {
|
|
|
43
43
|
|
|
44
44
|
const state = this.context.buildState(change, discovery.tests);
|
|
45
45
|
const questions = this.buildQuestions(discovery.tests, change.changedFiles.length === 0);
|
|
46
|
-
let
|
|
46
|
+
let answers: Record<string, { probability: number; confidence: number }>;
|
|
47
47
|
try {
|
|
48
|
-
|
|
48
|
+
answers = await this.judge.evaluate(state, questions);
|
|
49
49
|
} catch {
|
|
50
50
|
return {
|
|
51
51
|
change,
|
|
@@ -57,11 +57,9 @@ export class Sieve {
|
|
|
57
57
|
};
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
const evaluated = discovery.tests.map((test
|
|
61
|
-
const answer =
|
|
62
|
-
|
|
63
|
-
const confidence = extractConfidence(answer);
|
|
64
|
-
return { test, probability, confidence };
|
|
60
|
+
const evaluated = discovery.tests.map((test) => {
|
|
61
|
+
const answer = answers[test.identity.hash];
|
|
62
|
+
return { test, probability: answer?.probability ?? 0, confidence: answer?.confidence ?? 0 };
|
|
65
63
|
});
|
|
66
64
|
|
|
67
65
|
const ranked = this.policy.rank(evaluated);
|
|
@@ -103,9 +101,9 @@ export class Sieve {
|
|
|
103
101
|
|
|
104
102
|
const state = this.context.buildState(change, tests);
|
|
105
103
|
const questions = this.buildQuestions(tests, change.changedFiles.length === 0);
|
|
106
|
-
let
|
|
104
|
+
let answers: Record<string, { probability: number; confidence: number }>;
|
|
107
105
|
try {
|
|
108
|
-
|
|
106
|
+
answers = await this.judge.evaluate(state, questions);
|
|
109
107
|
} catch (error) {
|
|
110
108
|
return {
|
|
111
109
|
command: "select",
|
|
@@ -121,12 +119,12 @@ export class Sieve {
|
|
|
121
119
|
};
|
|
122
120
|
}
|
|
123
121
|
|
|
124
|
-
const evaluated = tests.map((test
|
|
125
|
-
const answer =
|
|
122
|
+
const evaluated = tests.map((test) => {
|
|
123
|
+
const answer = answers[test.identity.hash];
|
|
126
124
|
return {
|
|
127
125
|
test,
|
|
128
|
-
probability:
|
|
129
|
-
confidence:
|
|
126
|
+
probability: answer?.probability ?? 0,
|
|
127
|
+
confidence: answer?.confidence ?? 0,
|
|
130
128
|
};
|
|
131
129
|
});
|
|
132
130
|
|
|
@@ -156,10 +154,9 @@ export class Sieve {
|
|
|
156
154
|
}
|
|
157
155
|
|
|
158
156
|
private buildQuestions(tests: TestCase[], noChanges: boolean = false) {
|
|
159
|
-
const questions: Record<string,
|
|
157
|
+
const questions: Record<string, JudgeQuestion> = {};
|
|
160
158
|
for (const test of tests) {
|
|
161
159
|
questions[test.identity.hash] = {
|
|
162
|
-
type: "noul",
|
|
163
160
|
instructions: noChanges
|
|
164
161
|
? `No code changes detected. Could ${test.identity.framework} test at ${test.identity.path} still be affected by any latent issue?`
|
|
165
162
|
: `Could the current code change affect behavior verified by ${test.identity.framework} test at ${test.identity.path}?`,
|
|
@@ -169,23 +166,10 @@ export class Sieve {
|
|
|
169
166
|
}
|
|
170
167
|
}
|
|
171
168
|
|
|
172
|
-
function
|
|
173
|
-
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
function extractConfidence(answer: { noul?: number; confidence?: number }): number {
|
|
177
|
-
// The API's "noul" answers carry no explicit confidence field. Derive it
|
|
178
|
-
// from how decisive the probability itself is: a noul near 0 or 1 is a
|
|
179
|
-
// confident answer, a noul near 0.5 is genuine uncertainty (fail open).
|
|
180
|
-
if (answer.confidence !== undefined) return answer.confidence;
|
|
181
|
-
const noul = answer.noul ?? 0.5;
|
|
182
|
-
return Math.abs(noul - 0.5) * 2;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
export async function runSieve(framework: string, command: string, cwd?: string): Promise<any> {
|
|
186
|
-
const sieve = new Sieve(cwd);
|
|
169
|
+
export async function runLeanest(framework: string, command: string, cwd?: string): Promise<any> {
|
|
170
|
+
const leanest = new Leanest(cwd);
|
|
187
171
|
if (command === "inspect") {
|
|
188
|
-
return await
|
|
172
|
+
return await leanest.inspect(framework);
|
|
189
173
|
}
|
|
190
|
-
return await
|
|
174
|
+
return await leanest.select(framework);
|
|
191
175
|
}
|
package/src/jev-client.ts
DELETED
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
import { config } from "dotenv";
|
|
2
|
-
|
|
3
|
-
config({ quiet: true });
|
|
4
|
-
|
|
5
|
-
export const API_KEY = process.env.TYPESAFE_API_KEY ?? "";
|
|
6
|
-
export const API_BASE = process.env.TYPESAFE_API_BASE ?? "https://api.typesafe.ai/v1";
|
|
7
|
-
export const MODEL = process.env.TYPESAFE_MODEL ?? "jev-latest";
|
|
8
|
-
|
|
9
|
-
export interface JevQuestion {
|
|
10
|
-
type: "noul" | "choice" | "score";
|
|
11
|
-
instructions: string | object;
|
|
12
|
-
criteria?: object;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export interface JevState {
|
|
16
|
-
changedFiles: string[];
|
|
17
|
-
diff: string;
|
|
18
|
-
base: string;
|
|
19
|
-
head: string;
|
|
20
|
-
tests?: Array<{
|
|
21
|
-
id: string;
|
|
22
|
-
path: string;
|
|
23
|
-
suite: string[];
|
|
24
|
-
name: string;
|
|
25
|
-
source: string;
|
|
26
|
-
}>;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export interface JevResponse {
|
|
30
|
-
answers: Record<string, { noul?: number; choice?: string; score?: number; confidence?: number }>;
|
|
31
|
-
model?: string;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export class JevClient {
|
|
35
|
-
private apiKey: string;
|
|
36
|
-
private baseUrl: string;
|
|
37
|
-
private model: string;
|
|
38
|
-
|
|
39
|
-
constructor(apiKey?: string, baseUrl?: string, model?: string) {
|
|
40
|
-
this.apiKey = apiKey ?? API_KEY;
|
|
41
|
-
this.baseUrl = baseUrl ?? API_BASE;
|
|
42
|
-
this.model = model ?? MODEL;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
get hasApiKey(): boolean {
|
|
46
|
-
return this.apiKey.length > 0;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
async evaluate(state: JevState, questions: Record<string, JevQuestion>): Promise<JevResponse> {
|
|
50
|
-
if (!this.hasApiKey) {
|
|
51
|
-
throw new Error("TYPESAFE_API_KEY is not set. Export it or create a .env file.");
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const body = {
|
|
55
|
-
state,
|
|
56
|
-
questions,
|
|
57
|
-
model: this.model,
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
const response = await fetch(`${this.baseUrl}/systemone`, {
|
|
61
|
-
method: "POST",
|
|
62
|
-
headers: {
|
|
63
|
-
Authorization: `Bearer ${this.apiKey}`,
|
|
64
|
-
"Content-Type": "application/json",
|
|
65
|
-
},
|
|
66
|
-
body: JSON.stringify(body),
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
if (!response.ok) {
|
|
70
|
-
const text = await response.text();
|
|
71
|
-
throw new Error(`TypeSafe API error (${response.status}): ${text}`);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// SAFETY: TypeSafe /v1/systemone always returns a JSON response matching JevResponse shape
|
|
75
|
-
const data = (await response.json()) as JevResponse;
|
|
76
|
-
return data;
|
|
77
|
-
}
|
|
78
|
-
}
|