leanest 0.1.0 → 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 CHANGED
@@ -4,9 +4,11 @@
4
4
  [![npm version](https://img.shields.io/npm/v/leanest)](https://www.npmjs.com/package/leanest)
5
5
  [![CI](https://github.com/baronunread/leanest/actions/workflows/ci.yml/badge.svg)](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) semantic judgments. 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.
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
- Set your TypeSafe API key:
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
- That's the only configuration knob today. Framework choice, base ref, and target directory are all CLI flags (`--base`, `--dir`), so there's nothing else to set up per project.
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 [SIEVE_SPEC.md](./SIEVE_SPEC.md) for the design rationale behind the selection policy.
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.1.0",
4
- "description": "Local-first test selector using Jev judgments to determine which tests are affected by a code change",
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",
@@ -30,7 +31,7 @@
30
31
  "license": "MIT",
31
32
  "repository": {
32
33
  "type": "git",
33
- "url": "https://github.com/baronunread/leanest.git"
34
+ "url": "git+https://github.com/baronunread/leanest.git"
34
35
  },
35
36
  "bugs": {
36
37
  "url": "https://github.com/baronunread/leanest/issues"
@@ -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,10 +1,10 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
- import { Sieve } from "./sieve.js";
3
+ import { Leanest } from "./leanest.js";
4
4
  import { SelectionPolicy } from "./selection-policy.js";
5
5
  import { runTests } from "./runner.js";
6
6
 
7
- interface Flags {
7
+ export interface Flags {
8
8
  _: string[];
9
9
  changed?: boolean;
10
10
  json?: boolean;
@@ -26,12 +26,10 @@ async function main(): Promise<number> {
26
26
  const json = flags.json === true;
27
27
  const shadow = flags.shadow === true;
28
28
  const full = flags.full === true;
29
- // SAFETY: flags.dir is a directory path string or undefined
30
- const cwd = (flags.dir as string | undefined) ?? ".";
31
- // SAFETY: flags.base is a git ref string or undefined
32
- const base = flags.base as string | undefined;
29
+ const cwd = flags.dir ?? ".";
30
+ const base = flags.base;
33
31
 
34
- const sieve = new Sieve(cwd, base);
32
+ const leanest = new Leanest(cwd, base);
35
33
 
36
34
  if (command === "help" || !command) {
37
35
  printHelp();
@@ -40,12 +38,12 @@ async function main(): Promise<number> {
40
38
 
41
39
  switch (command) {
42
40
  case "inspect": {
43
- const result = await sieve.inspect(framework);
41
+ const result = await leanest.inspect(framework);
44
42
  printInspect(result);
45
43
  return 0;
46
44
  }
47
45
  case "select": {
48
- const result = await sieve.select(framework, changed);
46
+ const result = await leanest.select(framework, changed);
49
47
  if (json) {
50
48
  console.log(JSON.stringify(result, null, 2));
51
49
  } else {
@@ -55,7 +53,7 @@ async function main(): Promise<number> {
55
53
  }
56
54
  case "playwright":
57
55
  case "vitest": {
58
- const result = await sieve.select(command, changed);
56
+ const result = await leanest.select(command, changed);
59
57
  if (result.status === "error") {
60
58
  console.error(`⚠ Jev unavailable (${result.error}), running the full suite.`);
61
59
  }
@@ -95,21 +93,24 @@ async function main(): Promise<number> {
95
93
  }
96
94
  }
97
95
 
98
- function parseFlags(args: string[]): Flags {
96
+ const VALUE_FLAGS = new Set(["base", "dir"]);
97
+
98
+ export function parseFlags(args: string[]): Flags {
99
99
  const flags: Flags = { _: [] };
100
- for (const arg of args) {
101
- if (arg.startsWith("--")) {
100
+ for (let i = 0; i < args.length; i++) {
101
+ const arg = args[i];
102
+ if (arg?.startsWith("--")) {
102
103
  const key = arg.slice(2);
103
- if (flags[key] === undefined) {
104
+ const next = args[i + 1];
105
+ if (VALUE_FLAGS.has(key) && next !== undefined && !next.startsWith("-")) {
106
+ flags[key] = next;
107
+ i++;
108
+ } else {
104
109
  flags[key] = true;
105
- } else if (Array.isArray(flags[key])) {
106
- // SAFETY: flags[key] is already confirmed as string[] via Array.isArray check
107
- (flags[key] as string[]).push(arg);
108
110
  }
109
- } else if (arg.startsWith("-")) {
110
- const key = arg.slice(1);
111
- flags[key] = true;
112
- } else {
111
+ } else if (arg?.startsWith("-")) {
112
+ flags[arg.slice(1)] = true;
113
+ } else if (arg !== undefined) {
113
114
  flags._.push(arg);
114
115
  }
115
116
  }
@@ -204,9 +205,11 @@ Examples:
204
205
  `);
205
206
  }
206
207
 
207
- main()
208
- .then((code) => process.exit(code))
209
- .catch((error) => {
210
- console.error(error);
211
- process.exit(1);
212
- });
208
+ if (import.meta.main) {
209
+ main()
210
+ .then((code) => process.exit(code))
211
+ .catch((error) => {
212
+ console.error(error);
213
+ process.exit(1);
214
+ });
215
+ }
package/src/index.ts CHANGED
@@ -1,9 +1,10 @@
1
- export { runSieve, Sieve } from "./sieve.js";
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 { JevClient } from "./jev-client.js";
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 { JevClient, type JevQuestion, type JevResponse } from "./jev-client.js";
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 Sieve {
9
- private jev: JevClient;
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.jev = new JevClient();
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 response: JevResponse;
46
+ let answers: Record<string, { probability: number; confidence: number }>;
47
47
  try {
48
- response = await this.jev.evaluate(state, questions);
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, _i) => {
61
- const answer = response.answers[test.identity.hash];
62
- const probability = extractNoul(answer);
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 response: JevResponse;
104
+ let answers: Record<string, { probability: number; confidence: number }>;
107
105
  try {
108
- response = await this.jev.evaluate(state, questions);
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, _i) => {
125
- const answer = response.answers[test.identity.hash];
122
+ const evaluated = tests.map((test) => {
123
+ const answer = answers[test.identity.hash];
126
124
  return {
127
125
  test,
128
- probability: extractNoul(answer),
129
- confidence: extractConfidence(answer),
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, JevQuestion> = {};
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 extractNoul(answer: { noul?: number; confidence?: number }): number {
173
- return answer.noul ?? 0;
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 sieve.inspect(framework);
172
+ return await leanest.inspect(framework);
189
173
  }
190
- return await sieve.select(framework);
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
- }