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 +21 -0
- package/README.md +198 -0
- package/package.json +53 -0
- package/src/change-resolver.ts +77 -0
- package/src/cli.ts +212 -0
- package/src/context-builder.ts +19 -0
- package/src/git-diff.ts +83 -0
- package/src/index.ts +13 -0
- package/src/jev-client.ts +78 -0
- package/src/runner.ts +16 -0
- package/src/selection-policy.ts +20 -0
- package/src/sieve.ts +191 -0
- package/src/test-discovery.ts +106 -0
- package/src/types.ts +59 -0
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
|
+
[](https://github.com/baronunread/leanest/blob/main/LICENSE)
|
|
4
|
+
[](https://www.npmjs.com/package/leanest)
|
|
5
|
+
[](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
|
+
}
|
package/src/git-diff.ts
ADDED
|
@@ -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
|
+
}
|