leanest 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Leanest Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,198 @@
1
+ # Leanest
2
+
3
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/baronunread/leanest/blob/main/LICENSE)
4
+ [![npm version](https://img.shields.io/npm/v/leanest)](https://www.npmjs.com/package/leanest)
5
+ [![CI](https://github.com/baronunread/leanest/actions/workflows/ci.yml/badge.svg)](https://github.com/baronunread/leanest/actions)
6
+
7
+ > Leanest does not predict which tests will fail. It determines which tests are safe enough not to run.
8
+
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.
10
+
11
+ ---
12
+
13
+ ## Install
14
+
15
+ Requires [Bun](https://bun.sh): the CLI runs on it directly, no build step.
16
+
17
+ ```bash
18
+ bun add -D leanest
19
+ ```
20
+
21
+ Set your TypeSafe API key:
22
+
23
+ ```bash
24
+ export TYPESAFE_API_KEY="..."
25
+ ```
26
+
27
+ ## Quick Start
28
+
29
+ ```bash
30
+ npx leanest playwright # select + actually run the affected e2e tests
31
+ npx leanest playwright --base origin/main # diff against a specific base
32
+ npx leanest select playwright # just show the selection, don't run anything
33
+ npx leanest inspect playwright # rank every test by relevance, for debugging
34
+ ```
35
+
36
+ ## How It Works
37
+
38
+ ```
39
+ Repository
40
+ |
41
+ +-- Git change (base...head)
42
+ +-- Discovered test files
43
+ |
44
+ v
45
+ Leanest
46
+ |
47
+ +-- Change resolver (git diff)
48
+ +-- Test discovery (respects the framework's own config, e.g. playwright.config.ts testDir)
49
+ +-- Context builder (packages the diff + each test's source for Jev)
50
+ +-- Jev evaluator (one semantic judgment per test, in parallel)
51
+ +-- Selection policy (RUN / SKIP, fail-open on low confidence)
52
+ |
53
+ v
54
+ Selected test files
55
+ |
56
+ v
57
+ Your existing runner (playwright / vitest), unmodified
58
+ ```
59
+
60
+ For each discovered test, Leanest asks:
61
+
62
+ > **Could the current code change affect behavior verified by this test?**
63
+
64
+ Tests that are confidently irrelevant get skipped. Everything else runs through your existing runner exactly as it would outside Leanest: same reporter, same exit code, same flags.
65
+
66
+ ## Core Principles
67
+
68
+ - **Fail open**: uncertainty means RUN. A missing API key, an API timeout, or a malformed response always falls back to running the full suite, loudly (`⚠ Jev unavailable (...), running the full suite.`).
69
+ - **Deterministic overrides**: a test whose own file changed always runs. No threshold decides that.
70
+ - **Leanest doesn't run tests itself**: it selects file paths and hands them to your actual runner (`playwright test <paths>`, `vitest run <paths>`). It leaves reporters, retries, sharding, and CI-required-check behavior alone.
71
+ - **Static checks are out of scope on purpose**: lint/format/typecheck are already fast at full scope, and semantic per-rule selection would add latency for no real payoff. Leanest spends its Jev budget only on suites that are expensive to run in full: e2e today, more later.
72
+
73
+ ## Adapters
74
+
75
+ | Framework | Status | Command |
76
+ | ---------- | ----------- | -------------------------- |
77
+ | Playwright | First-class | `npx leanest playwright` |
78
+ | Vitest | First-class | `npx leanest vitest` |
79
+ | Jest | Planned | — |
80
+ | Pytest | Planned | — |
81
+
82
+ ## CLI Usage
83
+
84
+ ### Select and run (the normal case)
85
+
86
+ ```bash
87
+ npx leanest playwright
88
+ ```
89
+
90
+ ### Working tree only (uncommitted changes)
91
+
92
+ ```bash
93
+ npx leanest playwright --changed
94
+ ```
95
+
96
+ ### Specific base branch
97
+
98
+ ```bash
99
+ npx leanest playwright --base main
100
+ ```
101
+
102
+ ### Target a different directory
103
+
104
+ ```bash
105
+ npx leanest playwright --dir ~/projects/my-app
106
+ ```
107
+
108
+ ### Machine-readable output
109
+
110
+ ```bash
111
+ npx leanest playwright --json
112
+ ```
113
+
114
+ ### Selection only, no execution
115
+
116
+ ```bash
117
+ npx leanest select playwright --base origin/main
118
+ ```
119
+
120
+ ### Full suite, no selection
121
+
122
+ ```bash
123
+ npx leanest playwright --full
124
+ ```
125
+
126
+ ### Shadow mode
127
+
128
+ Runs the full suite for real (it skips nothing), but logs what Leanest would have skipped, so you can build trust in the selection before turning it on:
129
+
130
+ ```bash
131
+ npx leanest playwright --shadow
132
+ ```
133
+
134
+ ### Inspect mode (debugging / ranking)
135
+
136
+ ```bash
137
+ npx leanest inspect playwright
138
+ ```
139
+
140
+ ```
141
+ RUN tests/e2e/admin-users-export.pw.ts
142
+ RUN tests/e2e/downgrade.pw.ts
143
+ SKIP tests/e2e/qr-generator.pw.ts
144
+ SKIP tests/e2e/avatar.pw.ts
145
+ ...
146
+ ```
147
+
148
+ ## Configuration
149
+
150
+ Leanest loads `.env` for local convenience. The API key is never persisted or logged.
151
+
152
+ ```dotenv
153
+ TYPESAFE_API_KEY=...
154
+ ```
155
+
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.
157
+
158
+ ## CI Integration
159
+
160
+ ### GitHub Actions
161
+
162
+ ```yaml
163
+ - uses: actions/checkout@v4
164
+ with:
165
+ fetch-depth: 0
166
+
167
+ - uses: baronunread/leanest@v1
168
+ with:
169
+ framework: playwright
170
+ typesafe-api-key: ${{ secrets.TYPESAFE_API_KEY }}
171
+ ```
172
+
173
+ This installs Bun, installs `leanest`, and replaces your existing "run e2e tests" step: same reporter output, same exit code, just fewer tests executed.
174
+
175
+ ### Any other CI
176
+
177
+ ```bash
178
+ bun add -g leanest
179
+ leanest playwright --base origin/main
180
+ ```
181
+
182
+ Works anywhere you can run a shell command and set an env var: GitLab CI, CircleCI, Buildkite.
183
+
184
+ ## Development
185
+
186
+ ```bash
187
+ bun install
188
+ bun run check # lint + format check + typecheck + test
189
+ bun test # just the test suite
190
+ ```
191
+
192
+ ## Contributing
193
+
194
+ See [SIEVE_SPEC.md](./SIEVE_SPEC.md) for the design rationale behind the selection policy.
195
+
196
+ ## License
197
+
198
+ MIT. See [LICENSE](./LICENSE).
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
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",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "module": "./src/index.ts",
8
+ "types": "./src/index.ts",
9
+ "bin": {
10
+ "leanest": "src/cli.ts"
11
+ },
12
+ "exports": {
13
+ ".": {
14
+ "types": "./src/index.ts",
15
+ "import": "./src/index.ts"
16
+ }
17
+ },
18
+ "files": ["src/**", "!src/**/*.test.ts"],
19
+ "scripts": {
20
+ "lint": "bunx oxlint",
21
+ "format": "bunx oxfmt",
22
+ "test": "bun test",
23
+ "check": "bunx oxlint && bunx oxfmt --check && bunx style-doctor README.md && bun run typecheck && bun test",
24
+ "typecheck": "bunx tsc --noEmit",
25
+ "prepare": "bun run check",
26
+ "prepublishOnly": "bun run check"
27
+ },
28
+ "keywords": ["test", "selector", "jest", "vitest", "playwright", "jev", "typesafe", "ci"],
29
+ "author": "Leanest Contributors",
30
+ "license": "MIT",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "https://github.com/baronunread/leanest.git"
34
+ },
35
+ "bugs": {
36
+ "url": "https://github.com/baronunread/leanest/issues"
37
+ },
38
+ "homepage": "https://github.com/baronunread/leanest#readme",
39
+ "packageManager": "bun@1.4.1",
40
+ "dependencies": {
41
+ "dotenv": "^17.4.2"
42
+ },
43
+ "devDependencies": {
44
+ "@oxlint/plugins": "1.83.0",
45
+ "@types/bun": "^1.4.2",
46
+ "@types/node": "^22.20.3",
47
+ "oxfmt": "0.68.0",
48
+ "oxlint": "1.83.0",
49
+ "style-doctor": "0.3.0",
50
+ "tsx": "^4.23.13",
51
+ "typescript": "^7.0.2"
52
+ }
53
+ }
@@ -0,0 +1,77 @@
1
+ import { execSync } from "child_process";
2
+
3
+ export interface GitChange {
4
+ base: string;
5
+ head: string;
6
+ changedFiles: string[];
7
+ diff: string;
8
+ baseRef: string;
9
+ }
10
+
11
+ export class ChangeResolver {
12
+ private baseRef: string;
13
+
14
+ constructor(baseRef?: string) {
15
+ this.baseRef = baseRef ?? "main";
16
+ }
17
+
18
+ resolve(headRef?: string): GitChange {
19
+ const head = headRef ?? "HEAD";
20
+ const base = this.baseRef;
21
+ try {
22
+ // SAFETY: execSync returns a Buffer, we convert to string for text processing
23
+ const diff = execSync(`git diff ${base}...${head}`, { timeout: 30000 }) as Buffer;
24
+ // SAFETY: execSync returns a Buffer, we convert to string for file listing
25
+ const filesOutput = execSync(`git diff ${base}...${head} --name-only`, {
26
+ timeout: 30000,
27
+ }) as Buffer;
28
+ const changedFiles: string[] = filesOutput
29
+ .toString()
30
+ .trim()
31
+ .split("\n")
32
+ .filter((f: string) => f.length > 0);
33
+ return {
34
+ base,
35
+ head,
36
+ changedFiles,
37
+ diff: diff.toString(),
38
+ baseRef: base,
39
+ };
40
+ } catch {
41
+ return {
42
+ base,
43
+ head,
44
+ changedFiles: [],
45
+ diff: "",
46
+ baseRef: base,
47
+ };
48
+ }
49
+ }
50
+
51
+ resolveChangedOnly(): GitChange {
52
+ try {
53
+ // SAFETY: execSync returns a Buffer, we convert to string for file listing
54
+ const diff = execSync(`git diff --name-only`, { timeout: 30000 }) as Buffer;
55
+ const changedFiles: string[] = diff
56
+ .toString()
57
+ .trim()
58
+ .split("\n")
59
+ .filter((f: string) => f.length > 0);
60
+ return {
61
+ base: "working-tree",
62
+ head: "HEAD",
63
+ changedFiles,
64
+ diff: diff.toString(),
65
+ baseRef: "",
66
+ };
67
+ } catch {
68
+ return {
69
+ base: "working-tree",
70
+ head: "HEAD",
71
+ changedFiles: [],
72
+ diff: "",
73
+ baseRef: "",
74
+ };
75
+ }
76
+ }
77
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,212 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { Sieve } from "./sieve.js";
4
+ import { SelectionPolicy } from "./selection-policy.js";
5
+ import { runTests } from "./runner.js";
6
+
7
+ interface Flags {
8
+ _: string[];
9
+ changed?: boolean;
10
+ json?: boolean;
11
+ shadow?: boolean;
12
+ full?: boolean;
13
+ base?: string;
14
+ dir?: string;
15
+ [key: string]: boolean | string | string[] | undefined;
16
+ }
17
+
18
+ async function main(): Promise<number> {
19
+ const args = Bun.argv.slice(2);
20
+ const rawCommand = args[0] ?? "help";
21
+ const command = rawCommand === "--help" ? "help" : rawCommand;
22
+ const rest = rawCommand === "--help" ? args : args.slice(1);
23
+ const flags = parseFlags(rest);
24
+ const framework = flags._[0] ?? "playwright";
25
+ const changed = flags.changed === true;
26
+ const json = flags.json === true;
27
+ const shadow = flags.shadow === true;
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;
33
+
34
+ const sieve = new Sieve(cwd, base);
35
+
36
+ if (command === "help" || !command) {
37
+ printHelp();
38
+ return 0;
39
+ }
40
+
41
+ switch (command) {
42
+ case "inspect": {
43
+ const result = await sieve.inspect(framework);
44
+ printInspect(result);
45
+ return 0;
46
+ }
47
+ case "select": {
48
+ const result = await sieve.select(framework, changed);
49
+ if (json) {
50
+ console.log(JSON.stringify(result, null, 2));
51
+ } else {
52
+ printSelect(result);
53
+ }
54
+ return 0;
55
+ }
56
+ case "playwright":
57
+ case "vitest": {
58
+ const result = await sieve.select(command, changed);
59
+ if (result.status === "error") {
60
+ console.error(`⚠ Jev unavailable (${result.error}), running the full suite.`);
61
+ }
62
+
63
+ if (json) {
64
+ console.log(JSON.stringify(result, null, 2));
65
+ } else {
66
+ printSelect(result);
67
+ }
68
+
69
+ if (shadow) {
70
+ console.log(
71
+ `\nShadow mode: would run ${result.selectedTests.length} of ${result.totalTests} tests. Running full suite for real.`,
72
+ );
73
+ return await runTests(command, [], cwd);
74
+ }
75
+
76
+ if (full) {
77
+ console.log(`\nRunning the full suite (--full)...`);
78
+ return await runTests(command, [], cwd);
79
+ }
80
+
81
+ if (result.selectedTests.length === 0) {
82
+ console.log(`\nNothing to run.`);
83
+ return 0;
84
+ }
85
+
86
+ const paths = result.selectedTests.map((t) => t.identity.path);
87
+ console.log(`\nRunning ${command} on ${paths.length} selected test file(s)...`);
88
+ return await runTests(command, paths, cwd);
89
+ }
90
+ default: {
91
+ console.error(`Unknown command: ${command}`);
92
+ printHelp();
93
+ return 1;
94
+ }
95
+ }
96
+ }
97
+
98
+ function parseFlags(args: string[]): Flags {
99
+ const flags: Flags = { _: [] };
100
+ for (const arg of args) {
101
+ if (arg.startsWith("--")) {
102
+ const key = arg.slice(2);
103
+ if (flags[key] === undefined) {
104
+ 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
+ }
109
+ } else if (arg.startsWith("-")) {
110
+ const key = arg.slice(1);
111
+ flags[key] = true;
112
+ } else {
113
+ flags._.push(arg);
114
+ }
115
+ }
116
+ return flags;
117
+ }
118
+
119
+ function printInspect(result: any): void {
120
+ const { change, discovered } = result;
121
+ const noChanges = change.changedFiles.length === 0;
122
+ if (noChanges && discovered.count > 0) {
123
+ console.log(`No changes detected against ${change.base}...${change.head}`);
124
+ console.log(`Evaluating all ${discovered.count} tests conservatively...\n`);
125
+ } else {
126
+ console.log(`Change: ${change.base}...${change.head}`);
127
+ console.log(`\nChanged:`);
128
+ for (const f of change.changedFiles.slice(0, 20)) {
129
+ console.log(` ${f}`);
130
+ }
131
+ if (change.changedFiles.length > 20) {
132
+ console.log(` ... and ${change.changedFiles.length - 20} more`);
133
+ }
134
+ console.log(``);
135
+ }
136
+ console.log(`Discovering ${discovered.framework} tests...`);
137
+ console.log(` ${discovered.count} tests found`);
138
+ if (result.evaluated.length > 0) {
139
+ console.log(`\nEvaluating semantic impact...`);
140
+ console.log(` ${result.evaluated.length} tests evaluated`);
141
+ console.log(`\nSelected ${result.selected.length} / ${discovered.count} tests`);
142
+ const policy = new SelectionPolicy();
143
+ const ranked = result.evaluated.sort((a: any, b: any) => b.probability - a.probability);
144
+ for (const entry of ranked.slice(0, 20)) {
145
+ const decision = policy.decide(entry.probability, entry.confidence, false);
146
+ console.log(` ${decision} ${entry.test.identity.path}`);
147
+ }
148
+ if (ranked.length > 20) {
149
+ console.log(` ... and ${ranked.length - 20} more`);
150
+ }
151
+ }
152
+ console.log(`\nSkipping ${result.skipped} tests.`);
153
+ }
154
+
155
+ function printSelect(result: any): void {
156
+ const noChanges = result.changedFiles.length === 0;
157
+ if (noChanges && result.totalTests > 0) {
158
+ console.log(`No changes detected.`);
159
+ console.log(`Evaluating all ${result.totalTests} tests conservatively...\n`);
160
+ } else {
161
+ console.log(`Changed:`);
162
+ for (const f of result.changedFiles.slice(0, 20)) {
163
+ console.log(` ${f}`);
164
+ }
165
+ console.log(``);
166
+ }
167
+ console.log(`${result.totalTests} tests found`);
168
+ console.log(`\nSelected ${result.selectedTests.length} / ${result.totalTests} tests`);
169
+ for (const test of result.selectedTests.slice(0, 20)) {
170
+ console.log(` RUN ${test.identity.path}`);
171
+ }
172
+ if (result.skippedTests > 0) {
173
+ console.log(`\nSkipping ${result.skippedTests} tests.`);
174
+ }
175
+ }
176
+
177
+ function printHelp(): void {
178
+ console.log(`Usage: leanest <command> [options]
179
+
180
+ Commands:
181
+ inspect <framework> Rank tests by relevance (no execution)
182
+ select <framework> Select tests to run vs skip (no execution)
183
+ playwright [options] Select, then actually run Playwright on the selection
184
+ vitest [options] Select, then actually run Vitest on the selection
185
+
186
+ Options:
187
+ --changed Only changed files
188
+ --base <ref> Base branch (default: main)
189
+ --dir <path> Target directory (default: current directory)
190
+ --json Output JSON
191
+ --shadow Run the full suite, but also log what would have been skipped
192
+ --full Skip selection, run the full suite
193
+ --help Show this help
194
+
195
+ Examples:
196
+ npx leanest inspect playwright
197
+ npx leanest select playwright --base origin/main
198
+ npx leanest playwright --changed
199
+ npx leanest playwright --changed --json
200
+ npx leanest playwright --shadow
201
+ npx leanest playwright --full
202
+ npx leanest inspect playwright --dir /path/to/repo
203
+ leanest vitest --dir ~/projects/my-app --changed
204
+ `);
205
+ }
206
+
207
+ main()
208
+ .then((code) => process.exit(code))
209
+ .catch((error) => {
210
+ console.error(error);
211
+ process.exit(1);
212
+ });
@@ -0,0 +1,19 @@
1
+ import type { ChangeContext, TestCase } from "./types.js";
2
+
3
+ export class ContextBuilder {
4
+ buildState(change: ChangeContext, tests: TestCase[]) {
5
+ return {
6
+ changedFiles: change.changedFiles,
7
+ diff: change.diff.slice(0, 15000),
8
+ base: change.base,
9
+ head: change.head,
10
+ tests: tests.map((t) => ({
11
+ id: t.identity.hash,
12
+ path: t.identity.path,
13
+ suite: t.identity.suite,
14
+ name: t.identity.name,
15
+ source: t.source.slice(0, 2000),
16
+ })),
17
+ };
18
+ }
19
+ }
@@ -0,0 +1,83 @@
1
+ import { execSync } from "child_process";
2
+
3
+ export interface GitChange {
4
+ base: string;
5
+ head: string;
6
+ changedFiles: string[];
7
+ diff: string;
8
+ baseRef: string;
9
+ }
10
+
11
+ export class ChangeResolver {
12
+ private baseRef: string;
13
+ private cwd: string;
14
+
15
+ constructor(baseRef?: string, cwd?: string) {
16
+ this.baseRef = baseRef ?? "main";
17
+ this.cwd = cwd ?? ".";
18
+ }
19
+
20
+ resolve(headRef?: string): GitChange {
21
+ const head = headRef ?? "HEAD";
22
+ const base = this.baseRef;
23
+ try {
24
+ // SAFETY: execSync returns a Buffer, we convert to string for text processing
25
+ const diff = execSync(`git diff ${base}...${head}`, {
26
+ timeout: 30000,
27
+ cwd: this.cwd,
28
+ }) as Buffer;
29
+ // SAFETY: execSync returns a Buffer, we convert to string for file listing
30
+ const filesOutput = execSync(`git diff ${base}...${head} --name-only`, {
31
+ timeout: 30000,
32
+ cwd: this.cwd,
33
+ }) as Buffer;
34
+ const changedFiles: string[] = filesOutput
35
+ .toString()
36
+ .trim()
37
+ .split("\n")
38
+ .filter((f: string) => f.length > 0);
39
+ return {
40
+ base,
41
+ head,
42
+ changedFiles,
43
+ diff: diff.toString(),
44
+ baseRef: base,
45
+ };
46
+ } catch {
47
+ return {
48
+ base,
49
+ head,
50
+ changedFiles: [],
51
+ diff: "",
52
+ baseRef: base,
53
+ };
54
+ }
55
+ }
56
+
57
+ resolveChangedOnly(): GitChange {
58
+ try {
59
+ // SAFETY: execSync returns a Buffer, we convert to string for file listing
60
+ const diff = execSync(`git diff --name-only`, { timeout: 30000, cwd: this.cwd }) as Buffer;
61
+ const changedFiles: string[] = diff
62
+ .toString()
63
+ .trim()
64
+ .split("\n")
65
+ .filter((f: string) => f.length > 0);
66
+ return {
67
+ base: "working-tree",
68
+ head: "HEAD",
69
+ changedFiles,
70
+ diff: diff.toString(),
71
+ baseRef: "",
72
+ };
73
+ } catch {
74
+ return {
75
+ base: "working-tree",
76
+ head: "HEAD",
77
+ changedFiles: [],
78
+ diff: "",
79
+ baseRef: "",
80
+ };
81
+ }
82
+ }
83
+ }
package/src/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ export { runSieve, Sieve } from "./sieve.js";
2
+ export { SelectionPolicy } from "./selection-policy.js";
3
+ export { ChangeResolver } from "./git-diff.js";
4
+ export { ContextBuilder } from "./context-builder.js";
5
+ export { TestDiscovery } from "./test-discovery.js";
6
+ export { JevClient } from "./jev-client.js";
7
+ export type {
8
+ TestCase,
9
+ ChangeContext,
10
+ SelectionResult,
11
+ TestIdentity,
12
+ PipelineResult,
13
+ } from "./types.js";
@@ -0,0 +1,78 @@
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
+ }
package/src/runner.ts ADDED
@@ -0,0 +1,16 @@
1
+ const RUN_COMMANDS = {
2
+ playwright: (paths: string[]) => ["bunx", "playwright", "test", ...paths],
3
+ vitest: (paths: string[]) => ["bunx", "vitest", "run", ...paths],
4
+ } satisfies Record<string, (paths: string[]) => string[]>;
5
+
6
+ export function buildRunCommand(framework: string, paths: string[]): string[] {
7
+ // SAFETY: an unknown framework key just misses the lookup and falls back below
8
+ const build = RUN_COMMANDS[framework as keyof typeof RUN_COMMANDS] ?? RUN_COMMANDS.playwright;
9
+ return build(paths);
10
+ }
11
+
12
+ export async function runTests(framework: string, paths: string[], cwd: string): Promise<number> {
13
+ const argv = buildRunCommand(framework, paths);
14
+ const proc = Bun.spawn(argv, { cwd, stdio: ["inherit", "inherit", "inherit"] });
15
+ return await proc.exited;
16
+ }
@@ -0,0 +1,20 @@
1
+ import type { TestCase } from "./types.js";
2
+
3
+ export class SelectionPolicy {
4
+ decide(
5
+ probability: number | undefined | null,
6
+ confidence: number | undefined | null,
7
+ testChanged: boolean,
8
+ ): "RUN" | "SKIP" {
9
+ if (testChanged) return "RUN";
10
+ if (probability === undefined || probability === null) return "RUN";
11
+ if (confidence === undefined || confidence === null) return "RUN";
12
+ if (confidence < 0.5) return "RUN";
13
+ if (probability < 0.3) return "SKIP";
14
+ return "RUN";
15
+ }
16
+
17
+ rank(tests: Array<{ test: TestCase; probability: number; confidence: number }>) {
18
+ return [...tests].sort((a, b) => b.probability - a.probability);
19
+ }
20
+ }
package/src/sieve.ts ADDED
@@ -0,0 +1,191 @@
1
+ import { JevClient, type JevQuestion, type JevResponse } from "./jev-client.js";
2
+ import { ChangeResolver } from "./git-diff.js";
3
+ import { TestDiscovery } from "./test-discovery.js";
4
+ import { ContextBuilder } from "./context-builder.js";
5
+ import { SelectionPolicy } from "./selection-policy.js";
6
+ import type { TestCase, SelectionResult, PipelineResult } from "./types.js";
7
+
8
+ export class Sieve {
9
+ private jev: JevClient;
10
+ private git: ChangeResolver;
11
+ private discovery: TestDiscovery;
12
+ private context: ContextBuilder;
13
+ private policy: SelectionPolicy;
14
+
15
+ private cwd: string;
16
+
17
+ constructor(cwd?: string, baseRef?: string) {
18
+ this.cwd = cwd ?? ".";
19
+ this.jev = new JevClient();
20
+ this.git = new ChangeResolver(baseRef, this.cwd);
21
+ this.discovery = new TestDiscovery();
22
+ this.context = new ContextBuilder();
23
+ this.policy = new SelectionPolicy();
24
+ }
25
+
26
+ async inspect(framework: string): Promise<PipelineResult> {
27
+ const change = this.git.resolve();
28
+ const discovery =
29
+ framework === "playwright"
30
+ ? this.discovery.discoverPlaywright(this.cwd)
31
+ : this.discovery.discoverVitest(this.cwd);
32
+
33
+ if (discovery.tests.length === 0) {
34
+ return {
35
+ change,
36
+ discovered: { framework, count: 0, tests: [] },
37
+ evaluated: [],
38
+ selected: [],
39
+ skipped: 0,
40
+ decision: "RUN",
41
+ };
42
+ }
43
+
44
+ const state = this.context.buildState(change, discovery.tests);
45
+ const questions = this.buildQuestions(discovery.tests, change.changedFiles.length === 0);
46
+ let response: JevResponse;
47
+ try {
48
+ response = await this.jev.evaluate(state, questions);
49
+ } catch {
50
+ return {
51
+ change,
52
+ discovered: { framework, count: discovery.tests.length, tests: discovery.tests },
53
+ evaluated: [],
54
+ selected: [],
55
+ skipped: 0,
56
+ decision: "RUN",
57
+ };
58
+ }
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 };
65
+ });
66
+
67
+ const ranked = this.policy.rank(evaluated);
68
+ const selected = ranked
69
+ .filter((e) => this.policy.decide(e.probability, e.confidence, false) === "RUN")
70
+ .map((e) => e.test);
71
+
72
+ return {
73
+ change,
74
+ discovered: { framework, count: discovery.tests.length, tests: discovery.tests },
75
+ evaluated,
76
+ selected,
77
+ skipped: discovery.tests.length - selected.length,
78
+ decision: "RUN",
79
+ };
80
+ }
81
+
82
+ async select(framework: string, changedOnly: boolean = false): Promise<SelectionResult> {
83
+ const change = changedOnly ? this.git.resolveChangedOnly() : this.git.resolve();
84
+ const discovery =
85
+ framework === "playwright"
86
+ ? this.discovery.discoverPlaywright(this.cwd)
87
+ : this.discovery.discoverVitest(this.cwd);
88
+
89
+ const tests = discovery.tests;
90
+ if (tests.length === 0) {
91
+ return {
92
+ command: "select",
93
+ args: [framework],
94
+ status: "complete",
95
+ totalTests: 0,
96
+ selectedTests: [],
97
+ skippedTests: 0,
98
+ runTests: [],
99
+ changedFiles: change.changedFiles,
100
+ diff: change.diff,
101
+ };
102
+ }
103
+
104
+ const state = this.context.buildState(change, tests);
105
+ const questions = this.buildQuestions(tests, change.changedFiles.length === 0);
106
+ let response: JevResponse;
107
+ try {
108
+ response = await this.jev.evaluate(state, questions);
109
+ } catch (error) {
110
+ return {
111
+ command: "select",
112
+ args: [framework],
113
+ status: "error",
114
+ error: error instanceof Error ? error.message : String(error),
115
+ totalTests: tests.length,
116
+ selectedTests: tests,
117
+ skippedTests: 0,
118
+ runTests: tests,
119
+ changedFiles: change.changedFiles,
120
+ diff: change.diff,
121
+ };
122
+ }
123
+
124
+ const evaluated = tests.map((test, _i) => {
125
+ const answer = response.answers[test.identity.hash];
126
+ return {
127
+ test,
128
+ probability: extractNoul(answer),
129
+ confidence: extractConfidence(answer),
130
+ };
131
+ });
132
+
133
+ const ranked = this.policy.rank(evaluated);
134
+ const runTests: TestCase[] = [];
135
+ const skipTests: TestCase[] = [];
136
+
137
+ for (const entry of ranked) {
138
+ if (this.policy.decide(entry.probability, entry.confidence, false) === "RUN") {
139
+ runTests.push(entry.test);
140
+ } else {
141
+ skipTests.push(entry.test);
142
+ }
143
+ }
144
+
145
+ return {
146
+ command: "select",
147
+ args: [framework],
148
+ status: "complete",
149
+ totalTests: tests.length,
150
+ selectedTests: runTests,
151
+ skippedTests: skipTests.length,
152
+ runTests,
153
+ changedFiles: change.changedFiles,
154
+ diff: change.diff,
155
+ };
156
+ }
157
+
158
+ private buildQuestions(tests: TestCase[], noChanges: boolean = false) {
159
+ const questions: Record<string, JevQuestion> = {};
160
+ for (const test of tests) {
161
+ questions[test.identity.hash] = {
162
+ type: "noul",
163
+ instructions: noChanges
164
+ ? `No code changes detected. Could ${test.identity.framework} test at ${test.identity.path} still be affected by any latent issue?`
165
+ : `Could the current code change affect behavior verified by ${test.identity.framework} test at ${test.identity.path}?`,
166
+ };
167
+ }
168
+ return questions;
169
+ }
170
+ }
171
+
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);
187
+ if (command === "inspect") {
188
+ return await sieve.inspect(framework);
189
+ }
190
+ return await sieve.select(framework);
191
+ }
@@ -0,0 +1,106 @@
1
+ import { readFileSync, existsSync } from "fs";
2
+ import { Glob } from "bun";
3
+ import type { TestCase } from "./types.js";
4
+
5
+ export interface DiscoveryResult {
6
+ framework: string;
7
+ tests: TestCase[];
8
+ count: number;
9
+ }
10
+
11
+ const FRAMEWORK_PATTERNS = {
12
+ playwright: [
13
+ "**/*.spec.ts",
14
+ "**/*.spec.tsx",
15
+ "**/*.test.ts",
16
+ "**/*.test.tsx",
17
+ "**/*.pw.ts",
18
+ "**/*.pw.tsx",
19
+ ],
20
+ vitest: ["**/*.spec.ts", "**/*.spec.tsx", "**/*.test.ts", "**/*.test.tsx"],
21
+ } satisfies Record<string, string[]>;
22
+
23
+ export class TestDiscovery {
24
+ discoverPlaywright(baseDir: string = "."): DiscoveryResult {
25
+ const testDir = this.readPlaywrightTestDir(baseDir);
26
+ const scanDir = testDir ? `${baseDir === "." ? "" : `${baseDir}/`}${testDir}` : baseDir;
27
+ const tests = this.discoverTests("playwright", scanDir);
28
+ return { framework: "playwright", tests, count: tests.length };
29
+ }
30
+
31
+ private readPlaywrightTestDir(baseDir: string): string | null {
32
+ for (const name of ["playwright.config.ts", "playwright.config.js", "playwright.config.mjs"]) {
33
+ const configPath = `${baseDir === "." ? "." : baseDir}/${name}`;
34
+ if (!existsSync(configPath)) continue;
35
+ try {
36
+ const source = readFileSync(configPath, "utf-8");
37
+ const match = source.match(/testDir\s*:\s*["']([^"']+)["']/);
38
+ if (match) return match[1].replace(/^\.\//, "");
39
+ } catch {
40
+ // fall through to default scan
41
+ }
42
+ }
43
+ return null;
44
+ }
45
+
46
+ discoverVitest(baseDir: string = "."): DiscoveryResult {
47
+ const tests = this.discoverTests("vitest", baseDir);
48
+ return { framework: "vitest", tests, count: tests.length };
49
+ }
50
+
51
+ private discoverTests(framework: string, baseDir: string): TestCase[] {
52
+ const tests: TestCase[] = [];
53
+ // SAFETY: an unknown framework key just misses the lookup and falls back below
54
+ const patterns =
55
+ FRAMEWORK_PATTERNS[framework as keyof typeof FRAMEWORK_PATTERNS] ??
56
+ FRAMEWORK_PATTERNS.playwright;
57
+ const seen = new Set<string>();
58
+
59
+ for (const pattern of patterns) {
60
+ const glob = new Glob(pattern);
61
+ for (const file of glob.scanSync({ cwd: baseDir })) {
62
+ if (file.includes("node_modules/") || file.includes("dist/")) continue;
63
+ const path = baseDir === "." ? file : `${baseDir}/${file}`;
64
+ if (seen.has(path)) continue;
65
+ seen.add(path);
66
+ const test = this.extractTest(framework, path);
67
+ if (test) tests.push(test);
68
+ }
69
+ }
70
+
71
+ return tests;
72
+ }
73
+
74
+ private extractTest(framework: string, filePath: string): TestCase | null {
75
+ try {
76
+ const source = readFileSync(filePath, "utf-8");
77
+ const dir = filePath.split("/").slice(0, -1).join("/");
78
+ const filename = filePath.split("/").pop() ?? filePath;
79
+ const hash = this.hashString(filePath);
80
+
81
+ return {
82
+ identity: {
83
+ framework,
84
+ path: filePath,
85
+ suite: [dir],
86
+ name: filename,
87
+ hash,
88
+ },
89
+ source,
90
+ context: source.slice(0, 500),
91
+ };
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+
97
+ private hashString(str: string): string {
98
+ let hash = 0;
99
+ for (let i = 0; i < str.length; i++) {
100
+ const char = str.charCodeAt(i);
101
+ hash = (hash << 5) - hash + char;
102
+ hash = hash & hash;
103
+ }
104
+ return Math.abs(hash).toString(36);
105
+ }
106
+ }
package/src/types.ts ADDED
@@ -0,0 +1,59 @@
1
+ export interface TestIdentity {
2
+ framework: string;
3
+ path: string;
4
+ suite: string[];
5
+ name: string;
6
+ hash: string;
7
+ }
8
+
9
+ export interface TestCase {
10
+ identity: TestIdentity;
11
+ source: string;
12
+ context: string;
13
+ relevance?: number;
14
+ decision?: "RUN" | "SKIP";
15
+ }
16
+
17
+ export interface ChangeContext {
18
+ base: string;
19
+ head: string;
20
+ changedFiles: string[];
21
+ diff: string;
22
+ baseRef: string;
23
+ }
24
+
25
+ export interface TestContext {
26
+ framework: string;
27
+ relativePath: string;
28
+ suite: string[];
29
+ testName: string;
30
+ source: string;
31
+ helperContext: string;
32
+ }
33
+
34
+ export interface SelectionResult {
35
+ command: string;
36
+ args: string[];
37
+ status: string;
38
+ error?: string;
39
+ totalTests: number;
40
+ selectedTests: TestCase[];
41
+ skippedTests: number;
42
+ runTests: TestCase[];
43
+ decision?: string;
44
+ changedFiles: string[];
45
+ diff: string;
46
+ }
47
+
48
+ export interface PipelineResult {
49
+ change: ChangeContext;
50
+ discovered: { framework: string; count: number; tests: TestCase[] };
51
+ evaluated: Array<{
52
+ test: TestCase;
53
+ probability: number;
54
+ confidence: number;
55
+ }>;
56
+ selected: TestCase[];
57
+ skipped: number;
58
+ decision: "RUN" | "SKIP";
59
+ }