tewip-ai 0.54.0 → 0.56.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 +42 -4
- package/dist/engine/llm/client.js +12 -4
- package/dist/engine/llm/json-from-model.js +56 -0
- package/dist/engine/llm/llm-diagnose.js +2 -1
- package/dist/engine/llm/providers.js +8 -1
- package/dist/engine/quality/plan.js +2 -1
- package/dist/engine/quality/write.js +3 -2
- package/dist/engine/verdict/verdict.js +31 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -15,13 +15,42 @@ It works for every stack: Node.js (node:test, Jest, Vitest, Mocha), Python (pyte
|
|
|
15
15
|
| **Terminal** | `npm install -g tewip-ai`, then `tewip explain report.xml`, `tewip ci owner/repo --pr 12`, `tewip mcp` |
|
|
16
16
|
| **VS Code, Cursor, Windsurf, VSCodium** | the Tewip extension ([download](https://github.com/hmamut39/tewip/releases/tag/vscode-0.5.0)): registers Tewip with the editor's AI assistant and adds six commands - explain a report, why CI failed on this branch, the risk review of your change, which flaky tests to quarantine, the security check, and what your corrections add up to |
|
|
17
17
|
| **JetBrains, Claude, other AI assistants** | add the MCP server `tewip mcp`: fifteen tools, including *has this happened here before?*, *is this requirement covered and passing?* and *what should be done next about this goal?* (see [editor setup](#use-it-from-your-editor-any-ide-that-speaks-mcp)) |
|
|
18
|
-
| **Any model** | OpenAI, Claude, Gemini, Azure, or a model on your own machine with Ollama
|
|
18
|
+
| **Any model, including none** | OpenAI, Claude, Gemini, Azure, or a model on your own machine with Ollama - no account, no key, and no source code leaving the building: `TEWIP_PROVIDER=ollama`. Three quarters of what Tewip answers calls no model at all. [How](#running-with-no-data-leaving-the-building) |
|
|
19
19
|
| **GitHub CI** | the Tewip Action: a comment on every pull request, an optional merge gate, verified fix PRs ([QUICKSTART](QUICKSTART.md)) |
|
|
20
20
|
|
|
21
21
|
Formerly *triage-agent*: the repository moved to `hmamut39/tewip`, and GitHub redirects the old address. See [ROADMAP.md](ROADMAP.md) for the plan.
|
|
22
22
|
|
|
23
23
|
**Status: phases 1 to 14 are built; the two auto-fix phases (5 and 8) are the ones still in progress.** Tewip collects failures from any framework, diagnoses them with rules first and a model only for what rules cannot settle, and turns that into work for each role: a CI comment and merge gate, test plans and verified tests, a flake dossier, a PR-risk review, security scanning, tickets in Jira, Rally, GitHub Issues, Azure Boards or Linear, checks generated from a Figma design, specifications written for people, and - opt in, and only when proved - fixes to test or application code. It runs live in GitHub CI and answers questions from AI assistants through MCP. On fresh real Gutenberg CI failures, with the pipeline frozen before labelling, accuracy is 93.4%; across six real datasets it ranges from 86.1% to 99.7%, and the caveats are in [eval/README.md](eval/README.md).
|
|
24
24
|
|
|
25
|
+
|
|
26
|
+
## Running with no data leaving the building
|
|
27
|
+
|
|
28
|
+
A hospital, a bank or a government department usually cannot send source code to a hosted API, and
|
|
29
|
+
that is most of who this is built for. Tewip runs against a model on the same machine:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
# once
|
|
33
|
+
curl -fsSL https://ollama.com/install.sh | sh # or ollama.com for Windows and macOS
|
|
34
|
+
ollama pull qwen3:8b
|
|
35
|
+
|
|
36
|
+
# then
|
|
37
|
+
export TEWIP_PROVIDER=ollama
|
|
38
|
+
tewip done --diff main...HEAD
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
No key, no account, no network call off the machine. `TEWIP_BASE_URL` points the same path at
|
|
42
|
+
LM Studio, vLLM, llama.cpp's server, or anything else that speaks the OpenAI shape.
|
|
43
|
+
|
|
44
|
+
**What changes and what does not.** The deterministic layers - `vouch`, `schema`, `access`, `perf`,
|
|
45
|
+
`standards` - call no model at any time, with any provider, and are unaffected. Only the parts that
|
|
46
|
+
need judgement use one: explaining a failure, writing a test, drafting a plan. A smaller local model
|
|
47
|
+
is less accurate at those than a frontier one, and Tewip says which layer decided every answer, so
|
|
48
|
+
the difference is visible rather than hidden.
|
|
49
|
+
|
|
50
|
+
A local model is also less likely to be *constrained* to the schema it was asked for: some backends
|
|
51
|
+
accept the request and quietly do not enforce it. Tewip reads a reply that arrives inside a markdown
|
|
52
|
+
fence or after a sentence of preamble, and still refuses one with no JSON in it at all.
|
|
53
|
+
|
|
25
54
|
## What it costs
|
|
26
55
|
|
|
27
56
|
Most failures never reach a model. On 3,556 real CI failures from six public projects, the
|
|
@@ -1067,9 +1096,18 @@ that never run, and tests that compare no value at all. No model, no cost.
|
|
|
1067
1096
|
It is also an MCP tool, so a coding agent can ask about the tests it just wrote before claiming a
|
|
1068
1097
|
change is covered.
|
|
1069
1098
|
|
|
1070
|
-
**Measured on
|
|
1071
|
-
|
|
1072
|
-
|
|
1099
|
+
**Measured on 41,523 tests in six public projects** - Keycloak, OpenMRS, bitwarden/server, Saleor,
|
|
1100
|
+
GOV.UK Frontend and Firefly III - in Java, C#, Python, PHP and TypeScript. Every accusation was read
|
|
1101
|
+
against its source by hand, and the counts are kept in [`corpus/expected.json`](corpus/expected.json)
|
|
1102
|
+
against a pinned commit, re-derived by `npm run corpus` on every release.
|
|
1103
|
+
|
|
1104
|
+
**It has been badly wrong once, and that is worth saying plainly.** An earlier version of this
|
|
1105
|
+
paragraph reported 19 of 19 correct on 5,448 tests. Then Tewip was pointed at Keycloak and **called
|
|
1106
|
+
166 correct tests worthless** - every one of them the standard Java way of writing "I expect this to
|
|
1107
|
+
throw", which `Assert.fail()` makes fail properly because `AssertionError` is not an `Exception`.
|
|
1108
|
+
The rule had never read the caught type. 918 unit tests passed before that and after it, which is
|
|
1109
|
+
why the corpus exists: the thing that matters is what Tewip says about other people's code, and
|
|
1110
|
+
nothing was measuring it.
|
|
1073
1111
|
|
|
1074
1112
|
Two things it refuses to do, which is why the list is worth reading:
|
|
1075
1113
|
|
|
@@ -50,7 +50,7 @@ export function loadDotEnv() {
|
|
|
50
50
|
return;
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
|
-
export function openAiClient({ apiKey, model, reasoningEffort, baseUrl = 'https://api.openai.com/v1' }) {
|
|
53
|
+
export function openAiClient({ apiKey, model, reasoningEffort, baseUrl = 'https://api.openai.com/v1', provider = 'OpenAI', hint = '' }) {
|
|
54
54
|
return {
|
|
55
55
|
model: reasoningEffort ? `${model} (reasoning ${reasoningEffort})` : model,
|
|
56
56
|
async complete({ system, user, schemaName, schema }) {
|
|
@@ -75,8 +75,16 @@ export function openAiClient({ apiKey, model, reasoningEffort, baseUrl = 'https:
|
|
|
75
75
|
});
|
|
76
76
|
}
|
|
77
77
|
catch (error) {
|
|
78
|
-
if (attempt >= MAX_ATTEMPTS)
|
|
79
|
-
|
|
78
|
+
if (attempt >= MAX_ATTEMPTS) {
|
|
79
|
+
// "OpenAI request failed: TypeError: fetch failed" is what somebody running a model on
|
|
80
|
+
// their own machine used to see when they had not started it - the wrong provider named,
|
|
81
|
+
// and no mention of the one thing wrong. A local endpoint that refuses the connection is
|
|
82
|
+
// not an outage, it is a server nobody started yet, and the hint says how.
|
|
83
|
+
const local = /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])/.test(baseUrl);
|
|
84
|
+
throw new Error(local
|
|
85
|
+
? `no model is answering at ${baseUrl}. Nothing is listening there, so ${provider} is probably not running${hint ? ` - ${hint}` : ''}`
|
|
86
|
+
: `${provider} request failed: ${String(error)}`);
|
|
87
|
+
}
|
|
80
88
|
await sleep(2000 * attempt);
|
|
81
89
|
continue;
|
|
82
90
|
}
|
|
@@ -93,7 +101,7 @@ export function openAiClient({ apiKey, model, reasoningEffort, baseUrl = 'https:
|
|
|
93
101
|
}
|
|
94
102
|
const detail = truncate(await res.text(), 300);
|
|
95
103
|
if ((res.status !== 429 && res.status < 500) || attempt >= MAX_ATTEMPTS)
|
|
96
|
-
throw new Error(
|
|
104
|
+
throw new Error(`${provider} API ${res.status}: ${detail}`);
|
|
97
105
|
await sleep(2000 * attempt);
|
|
98
106
|
}
|
|
99
107
|
},
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// The JSON a model meant to send, out of whatever it actually sent.
|
|
2
|
+
//
|
|
3
|
+
// OpenAI's `response_format: json_schema` with `strict: true` constrains generation, so the content
|
|
4
|
+
// is a bare JSON object and `JSON.parse` is enough. That is the only provider this was ever run
|
|
5
|
+
// against, and it made the parsing look simpler than it is.
|
|
6
|
+
//
|
|
7
|
+
// Phase 47 put the real client in front of a local OpenAI-compatible server - the path a hospital or
|
|
8
|
+
// a bank has to use, because they cannot send source code to a hosted API - and two of the three
|
|
9
|
+
// commonest local-model replies crashed it:
|
|
10
|
+
//
|
|
11
|
+
// ```json\n{ ... }\n``` SyntaxError: Unexpected token '`'
|
|
12
|
+
// Here is the analysis:\n\n{...} SyntaxError: Unexpected token 'H'
|
|
13
|
+
//
|
|
14
|
+
// Self-hosted Ollama does constrain generation through llama.cpp's grammar decoder, but its own
|
|
15
|
+
// documentation records backends that accept `json_schema` and quietly do not enforce it, and every
|
|
16
|
+
// other local runner - LM Studio, vLLM, llama.cpp's own server - has its own answer. A fence and a
|
|
17
|
+
// sentence of preamble are not a model misbehaving; they are what an unconstrained model does.
|
|
18
|
+
//
|
|
19
|
+
// This is extraction, not repair. Nothing is guessed and nothing is rewritten: the fence comes off,
|
|
20
|
+
// the object between the outermost braces is taken, and anything that still will not parse throws
|
|
21
|
+
// with what actually arrived. A model that replies with prose and no JSON is still an error.
|
|
22
|
+
const FENCED = /^\s*```(?:json|jsonc|JSON)?\s*\n([\s\S]*?)\n?\s*```\s*$/;
|
|
23
|
+
/** Parses a model's reply as JSON, tolerating a fence or a sentence in front of it. */
|
|
24
|
+
export function jsonFromModel(raw) {
|
|
25
|
+
const attempts = [raw, ...candidates(raw)];
|
|
26
|
+
for (const text of attempts) {
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(text);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
throw new Error(`the model did not return JSON: ${raw.trim().slice(0, 200)}${raw.trim().length > 200 ? '…' : ''}`);
|
|
35
|
+
}
|
|
36
|
+
/** The shapes worth trying, in order of how sure we are about them. */
|
|
37
|
+
function candidates(raw) {
|
|
38
|
+
const out = [];
|
|
39
|
+
const fenced = FENCED.exec(raw.trim());
|
|
40
|
+
if (fenced)
|
|
41
|
+
out.push(fenced[1]);
|
|
42
|
+
// The outermost braces, which is what a sentence of preamble leaves behind. Taken from the first
|
|
43
|
+
// `{` to the last `}` rather than by counting: a brace inside a string would defeat the counter,
|
|
44
|
+
// and if the slice is unbalanced `JSON.parse` says so and the next candidate is tried.
|
|
45
|
+
const source = fenced ? fenced[1] : raw;
|
|
46
|
+
const open = source.indexOf('{');
|
|
47
|
+
const close = source.lastIndexOf('}');
|
|
48
|
+
if (open !== -1 && close > open)
|
|
49
|
+
out.push(source.slice(open, close + 1));
|
|
50
|
+
// The same for an array, which `write` and `plan` can both legitimately return.
|
|
51
|
+
const openArray = source.indexOf('[');
|
|
52
|
+
const closeArray = source.lastIndexOf(']');
|
|
53
|
+
if (openArray !== -1 && closeArray > openArray && (open === -1 || openArray < open))
|
|
54
|
+
out.push(source.slice(openArray, closeArray + 1));
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
@@ -4,9 +4,10 @@ import { recordFailure } from './usage.js';
|
|
|
4
4
|
import { ROOT_CAUSES } from '../../reporters/types.js';
|
|
5
5
|
import { truncate } from '../signature.js';
|
|
6
6
|
import { OUTPUT_SCHEMA, OUTPUT_SCHEMA_NAME, PROMPT_VERSION, SYSTEM_PROMPT } from './prompt.js';
|
|
7
|
+
import { jsonFromModel } from './json-from-model.js';
|
|
7
8
|
/** Validates the model's JSON. Throws on anything outside the contract. */
|
|
8
9
|
export function parseLlmOutput(raw) {
|
|
9
|
-
const value =
|
|
10
|
+
const value = jsonFromModel(raw);
|
|
10
11
|
if (!ROOT_CAUSES.includes(value.category))
|
|
11
12
|
throw new Error(`category outside the taxonomy: ${String(value.category)}`);
|
|
12
13
|
if (typeof value.confidence !== 'number' || !Number.isFinite(value.confidence))
|
|
@@ -45,7 +45,14 @@ export function modelChoiceFromEnv(purpose) {
|
|
|
45
45
|
export function clientFor(choice) {
|
|
46
46
|
if (choice.provider === 'anthropic')
|
|
47
47
|
return anthropicClient({ apiKey: choice.apiKey, model: choice.model, baseUrl: choice.baseUrl });
|
|
48
|
-
return openAiClient({
|
|
48
|
+
return openAiClient({
|
|
49
|
+
apiKey: choice.apiKey || 'local',
|
|
50
|
+
model: choice.model,
|
|
51
|
+
reasoningEffort: choice.reasoningEffort,
|
|
52
|
+
baseUrl: choice.baseUrl,
|
|
53
|
+
provider: choice.provider === 'custom' ? 'the configured endpoint' : choice.provider,
|
|
54
|
+
hint: PROVIDERS[choice.provider].hint,
|
|
55
|
+
});
|
|
49
56
|
}
|
|
50
57
|
/** What to tell someone who has no model configured, naming every way to get one. */
|
|
51
58
|
export function noModelHelp() {
|
|
@@ -7,6 +7,7 @@ import { existsSync, readdirSync } from 'node:fs';
|
|
|
7
7
|
import { join } from 'node:path';
|
|
8
8
|
import { readJsonl } from '../../reporters/store.js';
|
|
9
9
|
import { changeSummary, existingTests, readPages, relatedCodeSources, relatedTestSources } from './grounding.js';
|
|
10
|
+
import { jsonFromModel } from '../llm/json-from-model.js';
|
|
10
11
|
export const PLAN_PROMPT_VERSION = 'plan-4';
|
|
11
12
|
/** Failures Tewip already diagnosed in this project (from `tewip explain` runs kept in .tewip). */
|
|
12
13
|
function failureHistory(root, cap = 60) {
|
|
@@ -115,5 +116,5 @@ export function verifyCoverage(plan, facts) {
|
|
|
115
116
|
}
|
|
116
117
|
export async function writePlan(facts, model) {
|
|
117
118
|
const raw = await model.complete({ system: SYSTEM, user: factsForModel(facts), schemaName: 'tewip_test_plan', schema: SCHEMA });
|
|
118
|
-
return verifyCoverage(
|
|
119
|
+
return verifyCoverage(jsonFromModel(raw), facts);
|
|
119
120
|
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// When a draft fails, the model repairs it from the real error - or says the app looks wrong,
|
|
5
5
|
// because weakening a test until it passes would hide exactly the bug it exists to catch.
|
|
6
6
|
import { readFileSync } from 'node:fs';
|
|
7
|
+
import { jsonFromModel } from '../llm/json-from-model.js';
|
|
7
8
|
export const WRITE_PROMPT_VERSION = 'write-6';
|
|
8
9
|
const RULES = `Rules:
|
|
9
10
|
- Write in the given framework and language, in the style of the example test: the same imports, helpers, and way of opening pages (if it uses a base URL with relative paths, do the same).
|
|
@@ -48,7 +49,7 @@ function contextFor(ctx, scenario) {
|
|
|
48
49
|
}
|
|
49
50
|
export async function draftTest(model, ctx, scenario) {
|
|
50
51
|
const raw = await model.complete({ system: DRAFT_SYSTEM, user: JSON.stringify(contextFor(ctx, scenario)), schemaName: 'tewip_test_draft', schema: DRAFT_SCHEMA });
|
|
51
|
-
return
|
|
52
|
+
return jsonFromModel(raw);
|
|
52
53
|
}
|
|
53
54
|
/** The first lines of a failure: which check failed and how, without the page or the call log. */
|
|
54
55
|
export function failureHead(error) {
|
|
@@ -61,7 +62,7 @@ export async function repairTest(model, ctx, scenario, draft, error, earlier = [
|
|
|
61
62
|
const repeated = earlier.length > 0 ? { earlierFailures: earlier, note: 'Your earlier repairs failed too. If the same check keeps failing because the page does something other than the scenario expects, the fault is "app".' } : {};
|
|
62
63
|
const user = JSON.stringify({ ...contextFor(ctx, scenario), yourTest: draft.code, realError: error.slice(-10000), ...repeated });
|
|
63
64
|
const raw = await model.complete({ system: REPAIR_SYSTEM, user, schemaName: 'tewip_test_repair', schema: REPAIR_SCHEMA });
|
|
64
|
-
return
|
|
65
|
+
return jsonFromModel(raw);
|
|
65
66
|
}
|
|
66
67
|
/**
|
|
67
68
|
* The broken-on-purpose version of a test, or why none could be made. The piece to change must
|
|
@@ -25,11 +25,33 @@ export function decide(evidence) {
|
|
|
25
25
|
if (evidence.changedSource.length === 0) {
|
|
26
26
|
checks.push({ question: 'Is the changed code tested?', standing: 'not-applicable', answer: 'this change touches no source file', decidedBy: 'the diff' });
|
|
27
27
|
}
|
|
28
|
+
else if (evidence.covering.length === 0 && evidence.changedTests.length > 0) {
|
|
29
|
+
// Nothing *imports* the changed file, and the same change ships tests. Coverage here is followed
|
|
30
|
+
// through imports, which is a fact rather than a name match - and which cannot see a test that
|
|
31
|
+
// reaches the code through a framework's entry point. A Saleor test posts a GraphQL query and
|
|
32
|
+
// asserts the error it gets back; the resolver chain in between is never imported by name.
|
|
33
|
+
//
|
|
34
|
+
// Found in Phase 46 by running this verdict over six real merged Saleor pull requests. Four came
|
|
35
|
+
// back **Not ready**, and all four shipped a test for exactly the change they were making. A gate
|
|
36
|
+
// that says "not ready" to a correct pull request is uninstalled within a week, and it is the
|
|
37
|
+
// same overclaim as a false accusation, pointed at the change instead of at a test.
|
|
38
|
+
//
|
|
39
|
+
// So: cannot say. Which is this project's whole point, and this check had no way to say it.
|
|
40
|
+
checks.push({
|
|
41
|
+
question: 'Is the changed code tested?',
|
|
42
|
+
standing: 'not-checked',
|
|
43
|
+
answer: `no test imports or reaches ${evidence.changedSource.slice(0, 3).join(', ')}, but this change also ` +
|
|
44
|
+
`edits ${evidence.changedTests.length} test file(s). Coverage is followed through imports, which cannot see a test ` +
|
|
45
|
+
'that reaches the code through a GraphQL resolver, an HTTP route or any other framework entry point - ' +
|
|
46
|
+
'so whether those tests cover this is a question the imports cannot answer',
|
|
47
|
+
decidedBy: 'imports followed through the source, which is a fact rather than a name match',
|
|
48
|
+
});
|
|
49
|
+
}
|
|
28
50
|
else if (evidence.covering.length === 0) {
|
|
29
51
|
checks.push({
|
|
30
52
|
question: 'Is the changed code tested?',
|
|
31
53
|
standing: 'not-met',
|
|
32
|
-
answer: `no test imports or reaches ${evidence.changedSource.slice(0, 3).join(', ')}, so nothing here could notice a mistake in it`,
|
|
54
|
+
answer: `no test imports or reaches ${evidence.changedSource.slice(0, 3).join(', ')}, and this change adds no test either, so nothing here could notice a mistake in it`,
|
|
33
55
|
decidedBy: 'imports followed through the source, which is a fact rather than a name match',
|
|
34
56
|
});
|
|
35
57
|
}
|
|
@@ -50,7 +72,7 @@ export function decide(evidence) {
|
|
|
50
72
|
question: 'Can the tests in this change fail?',
|
|
51
73
|
standing: 'not-met',
|
|
52
74
|
answer: `${evidence.cannotFail.length} of them cannot: ${evidence.cannotFail.slice(0, 2).map((t) => `${t.name} (${t.file}:${t.line})`).join(', ')}`,
|
|
53
|
-
decidedBy: 'tewip vouch,
|
|
75
|
+
decidedBy: 'tewip vouch, measured on 41,523 real tests in the six public projects in corpus/expected.json',
|
|
54
76
|
});
|
|
55
77
|
}
|
|
56
78
|
else {
|
|
@@ -120,7 +142,13 @@ export function decide(evidence) {
|
|
|
120
142
|
headline: broken.length > 0
|
|
121
143
|
? `Not ready: ${broken[0].answer}`
|
|
122
144
|
: verdict === 'cannot-say'
|
|
123
|
-
?
|
|
145
|
+
? // Quoted, because the questions are questions: lowercasing them and joining with "and"
|
|
146
|
+
// produced "is anything failing and is the changed code tested could not be checked",
|
|
147
|
+
// which is the headline of the verdict this whole tool is named for.
|
|
148
|
+
`Cannot say: ${essential.filter((c) => c.standing === 'not-checked').length === 1 ? 'one question that matters' : 'two questions that matter'} here went unanswered - ${essential
|
|
149
|
+
.filter((c) => c.standing === 'not-checked')
|
|
150
|
+
.map((c) => `"${c.question}"`)
|
|
151
|
+
.join(' and ')} - so this is not an approval`
|
|
124
152
|
: `Ready as far as ${checks.filter((c) => c.standing === 'met').length} check(s) can show${unknown.length > 0 ? `, with ${unknown.length} not checked` : ''}`,
|
|
125
153
|
checks,
|
|
126
154
|
notChecked: [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tewip-ai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.56.0",
|
|
4
4
|
"mcpName": "io.github.hmamut39/tewip",
|
|
5
5
|
"description": "Tewip finds why each failing test failed - real bug, flaky test, environment, test data or changed selector - for any stack, from the command line, CI, or your editor.",
|
|
6
6
|
"keywords": [
|