tewip-ai 0.70.16 → 0.70.17
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/dist/engine/flaky/flaky.js +15 -0
- package/dist/engine/review/review-command.js +8 -2
- package/dist/engine/review/review.js +14 -0
- package/dist/engine/trace/trace-command.js +1 -1
- package/dist/engine/trace/trace.js +20 -3
- package/dist/engine/verdict/evidence.js +12 -0
- package/dist/engine/verdict/render.js +22 -0
- package/dist/engine/verdict/verdict-command.js +23 -4
- package/dist/engine/verdict/verdict.js +52 -30
- package/dist/engine/vouch/changed.js +2 -1
- package/dist/engine/vouch/text.js +15 -2
- package/package.json +1 -1
|
@@ -95,6 +95,21 @@ export function decide(test, { minRuns, alwaysFailsAbove }) {
|
|
|
95
95
|
//
|
|
96
96
|
// Phase 56 is where this comes from: of the signals available when a test fails, non-determinism
|
|
97
97
|
// is the one that is genuinely decisive, and it costs nothing to read.
|
|
98
|
+
// This is also what makes quarantine safe, and not the rule above it.
|
|
99
|
+
//
|
|
100
|
+
// The rail that refuses to quarantine a test whose failures looked like the application being
|
|
101
|
+
// wrong reads the *diagnosed* category, and on real stored history there is none: across
|
|
102
|
+
// gutenberg, gutenberg-aug2 and supabase, all 3,249 harvested failures have no diagnosis at all,
|
|
103
|
+
// because nobody ran `tewip explain` over them. So the rail never fires.
|
|
104
|
+
//
|
|
105
|
+
// Measured anyway, against 2,773 hand labels (`node eval/flaky-safety.ts`): of 241 tests
|
|
106
|
+
// recommended for quarantine, 218 had a labelled failure and **none of them was ever labelled a
|
|
107
|
+
// real bug**. All 68 tests that were catching real bugs came back `watch`, never `quarantine`.
|
|
108
|
+
//
|
|
109
|
+
// The safety is real and its source is this condition, not the rail: a test that failed and then
|
|
110
|
+
// passed on a retry of the same commit did both on the same code. A test catching a real bug does
|
|
111
|
+
// not pass on a retry. That is an observation, which is why it still works when every diagnosis
|
|
112
|
+
// is missing.
|
|
98
113
|
if (test.recoveredOnRetry > 0 && test.failedRuns >= minRuns) {
|
|
99
114
|
const times = test.recoveredOnRetry === 1 ? 'once' : `${test.recoveredOnRetry} times`;
|
|
100
115
|
const spread = test.branches.length > 1 ? ` It failed on ${test.branches.length} different branches, so it is the test rather than one person's change.` : '';
|
|
@@ -5,7 +5,7 @@ import { changeSummary } from '../quality/grounding.js';
|
|
|
5
5
|
import { chooseFramework, detectFrameworks } from '../quality/frameworks.js';
|
|
6
6
|
import { readTriageRecords, datasetFiles } from '../../reporters/store.js';
|
|
7
7
|
import { coverageFor, uncovered } from './coverage.js';
|
|
8
|
-
import { renderReview, reviewChange } from './review.js';
|
|
8
|
+
import { renderReview, reviewChange, runFrom } from './review.js';
|
|
9
9
|
import { changedExpectations } from './expectations.js';
|
|
10
10
|
import { changedAgreements } from './agreements.js';
|
|
11
11
|
import { changedTestLines, vouchChanged } from '../vouch/changed.js';
|
|
@@ -35,7 +35,13 @@ export async function runReview(cmd) {
|
|
|
35
35
|
// Printing a Playwright command for a file Playwright never runs is worse than printing nothing.
|
|
36
36
|
const framework = chooseFramework(detectFrameworks(cmd.root), undefined, false) ?? detectFrameworks(cmd.root)[0] ?? null;
|
|
37
37
|
const ownsThem = framework ? review.runFirst.some((f) => resolve(cmd.root, f).startsWith(resolve(framework.testDir))) : false;
|
|
38
|
-
|
|
38
|
+
// And from the directory the runner actually runs in. `framework.command` builds paths relative
|
|
39
|
+
// to the framework's own cwd, which in a layered project is not where the report is read: this
|
|
40
|
+
// printed `node --test "test/flaky.test.ts"` for a file at engine/test/flaky.test.ts, and copying
|
|
41
|
+
// it gave "Could not find 'test/flaky.test.ts'". The one actionable line in a review has to work
|
|
42
|
+
// where somebody pastes it.
|
|
43
|
+
const prefix = framework ? runFrom(cmd.root, framework.cwd) : '';
|
|
44
|
+
const runCommand = framework && ownsThem ? (chosen) => prefix + chosen.map((f) => framework.command(resolve(cmd.root, f))).join(' && ') : undefined;
|
|
39
45
|
// Whether the tests in this change could ever fail. Costs nothing, calls no model, and is the one
|
|
40
46
|
// question an agent-written diff most needs asked of it.
|
|
41
47
|
const vouched = changedTestLines(vouchChanged(cmd.root, files));
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { relative, sep } from 'node:path';
|
|
1
2
|
// The risk review of a change, from what Tewip already knows about this project.
|
|
2
3
|
//
|
|
3
4
|
// A general coding agent can read a diff. What it cannot do is tell you that the two tests covering
|
|
@@ -98,6 +99,19 @@ const when = (at) => {
|
|
|
98
99
|
return days <= 1 ? 'yesterday' : days < 14 ? `${days} days ago` : days < 60 ? `${Math.round(days / 7)} weeks ago` : `${Math.round(days / 30)} months ago`;
|
|
99
100
|
};
|
|
100
101
|
/** The review as a person reads it, before they read the diff. */
|
|
102
|
+
/**
|
|
103
|
+
* The `cd` a suggested command needs, when the runner does not run from the repository root.
|
|
104
|
+
*
|
|
105
|
+
* A framework builds its commands relative to its own working directory, which in a layered
|
|
106
|
+
* project is not where the report is read. `tewip review` printed
|
|
107
|
+
* `node --test "test/flaky.test.ts"` for a file at engine/test/flaky.test.ts, and copying it gave
|
|
108
|
+
* "Could not find 'test/flaky.test.ts'". The command is the one line in a review somebody acts on;
|
|
109
|
+
* it has to work where they paste it.
|
|
110
|
+
*/
|
|
111
|
+
export function runFrom(root, cwd) {
|
|
112
|
+
const where = relative(root, cwd).split(sep).join('/');
|
|
113
|
+
return where && where !== '.' ? `cd ${where} && ` : '';
|
|
114
|
+
}
|
|
101
115
|
export function renderReview(review, runCommand) {
|
|
102
116
|
const lines = [`# Risk review: ${review.range}`, '', `${review.files.length} changed file(s).`, ''];
|
|
103
117
|
if (review.notes.length > 0) {
|
|
@@ -51,7 +51,7 @@ export function traceReport(cmd) {
|
|
|
51
51
|
const found = plans(cmd.root, cmd.only);
|
|
52
52
|
const { runs, records } = history(cmd.root, cmd.name);
|
|
53
53
|
const traced = found.map((plan) => tracePlan(plan, runs, records));
|
|
54
|
-
return { traced, report: renderTrace(traced), plans: found.length, runs: runs.length };
|
|
54
|
+
return { traced, report: renderTrace(traced, runs.length), plans: found.length, runs: runs.length };
|
|
55
55
|
}
|
|
56
56
|
export function runTrace(cmd) {
|
|
57
57
|
const { traced, report, plans: found, runs } = traceReport(cmd);
|
|
@@ -78,7 +78,16 @@ export function tracePlan(plan, runs, records) {
|
|
|
78
78
|
}
|
|
79
79
|
const MARK = { passing: 'passing', failing: 'FAILING', 'never-run': 'never run', 'not-covered': 'NOT COVERED' };
|
|
80
80
|
/** The answer to "is this covered, and is it passing", written so it can be disagreed with. */
|
|
81
|
-
|
|
81
|
+
/**
|
|
82
|
+
* The report.
|
|
83
|
+
*
|
|
84
|
+
* `runsKept` is passed separately because the scenarios cannot carry it. With no run history at
|
|
85
|
+
* all, every scenario comes back `never run` - true of each one and misleading as a whole: it reads
|
|
86
|
+
* as a measurement of a suite nobody has exercised, when the truth is that nothing has been
|
|
87
|
+
* recorded here to measure. The footer made it worse, claiming the lines came from "a run it
|
|
88
|
+
* recorded" when there was no run.
|
|
89
|
+
*/
|
|
90
|
+
export function renderTrace(traced, runsKept = 1) {
|
|
82
91
|
const all = traced.flatMap((t) => t.scenarios);
|
|
83
92
|
const count = (s) => all.filter((x) => x.standing === s).length;
|
|
84
93
|
const lines = ['# From what was asked for, to what is passing', ''];
|
|
@@ -90,7 +99,13 @@ export function renderTrace(traced) {
|
|
|
90
99
|
'writes one, and `tewip write` turns its scenarios into tests.',
|
|
91
100
|
].join('\n');
|
|
92
101
|
}
|
|
93
|
-
lines.push(`${all.length} scenario(s) across ${traced.length} requirement(s): ${count('passing')} passing, ${count('failing')} failing, ${count('never-run')} never run, ${count('not-covered')} not covered.`, ''
|
|
102
|
+
lines.push(`${all.length} scenario(s) across ${traced.length} requirement(s): ${count('passing')} passing, ${count('failing')} failing, ${count('never-run')} never run, ${count('not-covered')} not covered.`, '');
|
|
103
|
+
if (runsKept === 0) {
|
|
104
|
+
lines.push('**No run has been kept here, so nothing below says whether anything passes.** Every scenario', 'reads "never run" because there is no run to have attempted it - not because the tests were', 'written and then left alone. Run `tewip explain <report>` on a real run first, and this becomes', 'an answer rather than a list.', '');
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
lines.push('A scenario counts as covered only when a real test can be named, and as passing only when a real', 'run attempted it. Everything else says what is missing instead.', '');
|
|
108
|
+
}
|
|
94
109
|
for (const requirement of traced) {
|
|
95
110
|
lines.push(`## ${requirement.requirement}${requirement.ticket ? ` (${requirement.ticket})` : ''}`, '');
|
|
96
111
|
for (const s of requirement.scenarios) {
|
|
@@ -112,6 +127,8 @@ export function renderTrace(traced) {
|
|
|
112
127
|
lines.push(`- ${s.standing === 'failing' ? `fix or triage ${s.test}` : `write a test for ${s.id}: ${s.title}`}`);
|
|
113
128
|
lines.push('');
|
|
114
129
|
}
|
|
115
|
-
lines.push(
|
|
130
|
+
lines.push(runsKept === 0
|
|
131
|
+
? 'Every line above comes from a plan Tewip wrote. No run was recorded here, so nothing above was checked against one.'
|
|
132
|
+
: 'Every line above comes from a plan Tewip wrote and a run it recorded. Nothing is inferred.');
|
|
116
133
|
return lines.join('\n');
|
|
117
134
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// What a verdict is made of: the questions, the standings, and everything `decide` is allowed to
|
|
2
|
+
// read. Split from verdict.ts when it passed this project's 300-line rule.
|
|
3
|
+
//
|
|
4
|
+
// Worth keeping together, because the shape of this file is the argument. Every field that can be
|
|
5
|
+
// absent is nullable or counted, and the comments say which absence means what - an empty list and
|
|
6
|
+
// a question nobody answered look identical otherwise, and four separate phases found the gate
|
|
7
|
+
// reading one as the other.
|
|
8
|
+
export {};
|
|
9
|
+
/**
|
|
10
|
+
* The verdict, in a fixed order: what is broken first, then what cannot be believed, then what is
|
|
11
|
+
* missing, and only then "ready".
|
|
12
|
+
*/
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export function renderDecision(range, decision) {
|
|
2
|
+
const mark = { met: 'yes', 'not-met': '**no**', 'not-applicable': 'n/a', 'not-checked': 'not checked' };
|
|
3
|
+
const title = decision.verdict === 'ready' ? 'Ready, as far as this can be shown' : decision.verdict === 'not-ready' ? 'Not ready' : 'Cannot say';
|
|
4
|
+
const lines = [
|
|
5
|
+
`# Is this work done? ${title}`,
|
|
6
|
+
'',
|
|
7
|
+
`For \`${range}\`. ${decision.headline}.`,
|
|
8
|
+
'',
|
|
9
|
+
'| Question | Answer |',
|
|
10
|
+
'| --- | --- |',
|
|
11
|
+
...decision.checks.map((c) => `| ${c.question} | ${mark[c.standing]} |`),
|
|
12
|
+
'',
|
|
13
|
+
];
|
|
14
|
+
for (const check of decision.checks) {
|
|
15
|
+
if (check.standing === 'not-applicable')
|
|
16
|
+
continue;
|
|
17
|
+
const said = `${mark[check.standing] === '**no**' ? 'No' : mark[check.standing] === 'yes' ? 'Yes' : 'Not checked'}: ${check.answer}`;
|
|
18
|
+
lines.push(`### ${check.question}`, '', /[.!?]$/.test(said) ? said : `${said}.`, '', `_Decided by ${check.decidedBy}._`, '');
|
|
19
|
+
}
|
|
20
|
+
lines.push('## What this says nothing about', '', ...decision.notChecked.map((n) => `- ${n}`), '', '---', '', 'Nothing above is judged here: each answer comes from a layer with its own measured accuracy, and', 'this only puts them in a fixed order - what is broken, then what cannot be believed, then what is', 'missing. "Cannot say" is a real answer and is never rounded up to ready.');
|
|
21
|
+
return lines.join(String.fromCharCode(10));
|
|
22
|
+
}
|
|
@@ -37,13 +37,23 @@ function history(root, only) {
|
|
|
37
37
|
return { runs, records };
|
|
38
38
|
}
|
|
39
39
|
/** Scenarios of kept plans that this change relates to and nothing covers. Null when no plans exist. */
|
|
40
|
+
/**
|
|
41
|
+
* Scenarios this change touches that no test covers, and what stopped the question being answered.
|
|
42
|
+
*
|
|
43
|
+
* The gaps alone cannot carry it. An empty list means one of three different things: every scenario
|
|
44
|
+
* is covered, no plan here is about this change, or every plan failed to parse and was silently
|
|
45
|
+
* skipped - and the gate read all three as "every scenario this change relates to has a test".
|
|
46
|
+
* Measured on a project with one plan file containing the word "refunds" and no valid JSON.
|
|
47
|
+
*/
|
|
40
48
|
function uncoveredFor(root, changed, attempted) {
|
|
41
49
|
const dir = join(root, '.tewip', 'plans');
|
|
42
50
|
if (!existsSync(dir))
|
|
43
|
-
return null;
|
|
51
|
+
return { gaps: null, unreadable: 0, considered: 0 };
|
|
44
52
|
const words = new Set(changed.flatMap((f) => (f.toLowerCase().match(/[a-z][a-z0-9]{3,}/g) ?? [])));
|
|
45
53
|
const clean = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '');
|
|
46
54
|
const out = [];
|
|
55
|
+
let unreadable = 0;
|
|
56
|
+
let considered = 0;
|
|
47
57
|
for (const file of readdirSync(dir).filter((f) => f.endsWith('.json'))) {
|
|
48
58
|
try {
|
|
49
59
|
const saved = JSON.parse(readFileSync(join(dir, file), 'utf8'));
|
|
@@ -55,6 +65,7 @@ function uncoveredFor(root, changed, attempted) {
|
|
|
55
65
|
const about = (`${plan.title ?? ''} ${saved.feature ?? ''}`.toLowerCase().match(/[a-z][a-z0-9]{3,}/g) ?? []).some((w) => words.has(w));
|
|
56
66
|
if (!about)
|
|
57
67
|
continue;
|
|
68
|
+
considered += 1;
|
|
58
69
|
for (const scenario of plan.scenarios) {
|
|
59
70
|
const covered = (scenario.covered_by ?? '').trim() || attempted.find((t) => clean(t).includes(clean(scenario.id)) && clean(scenario.id).length >= 4);
|
|
60
71
|
if (!covered)
|
|
@@ -62,10 +73,12 @@ function uncoveredFor(root, changed, attempted) {
|
|
|
62
73
|
}
|
|
63
74
|
}
|
|
64
75
|
catch {
|
|
65
|
-
//
|
|
76
|
+
// Skipped in silence until Phase 112: an unparseable plan left the gap list empty,
|
|
77
|
+
// which the gate read as full coverage.
|
|
78
|
+
unreadable += 1;
|
|
66
79
|
}
|
|
67
80
|
}
|
|
68
|
-
return out;
|
|
81
|
+
return { gaps: out, unreadable, considered };
|
|
69
82
|
}
|
|
70
83
|
export function gather(cmd) {
|
|
71
84
|
const change = changeSummary(cmd.root, cmd.diff);
|
|
@@ -112,6 +125,7 @@ export function gather(cmd) {
|
|
|
112
125
|
})
|
|
113
126
|
.map((r) => ({ testId: r.testId, category: String(r.diagnosis?.category ?? 'UNKNOWN') }));
|
|
114
127
|
const standards = loadStandards(cmd.root);
|
|
128
|
+
const plans = uncoveredFor(cmd.root, changed, Object.keys(newest?.attempts ?? {}));
|
|
115
129
|
const brokenRules = standards.length === 0 ? null : checkChanged(cmd.root, standards, changed).map((v) => ({ standard: v.standard, file: v.file, line: v.line, why: v.rule.because }));
|
|
116
130
|
const proved = cmd.prove ? proveChanged(cmd.root, changedSource, { budgetSeconds: cmd.proveBudgetSeconds ?? 300, onStep: (m) => console.error(` ${m}`) }) : null;
|
|
117
131
|
return {
|
|
@@ -123,10 +137,15 @@ export function gather(cmd) {
|
|
|
123
137
|
coverage,
|
|
124
138
|
cannotFail: vouched.cannotFail.map((j) => ({ name: j.name, file: j.file, line: j.line, why: j.why })),
|
|
125
139
|
crashOnly: vouched.crashOnly.length,
|
|
140
|
+
couldNotJudge: vouched.couldNotJudge.length,
|
|
141
|
+
proofsTried: proved ? proved.proofs.reduce((n, pr) => n + pr.tried.length, 0) : 0,
|
|
142
|
+
proofSkipped: proved?.skipped ?? '',
|
|
126
143
|
unnoticed: proved ? proved.proofs.flatMap((p) => p.tried.filter((t) => t.fate === 'unnoticed').map((t) => ({ file: p.file, line: t.mutant.line, kind: t.mutant.kind }))) : null,
|
|
127
144
|
failingNow,
|
|
128
145
|
noHistory: runs.length === 0,
|
|
129
|
-
uncovered:
|
|
146
|
+
uncovered: plans.gaps,
|
|
147
|
+
plansUnreadable: plans.unreadable,
|
|
148
|
+
plansConsidered: plans.considered,
|
|
130
149
|
brokenRules,
|
|
131
150
|
},
|
|
132
151
|
};
|
|
@@ -1,25 +1,27 @@
|
|
|
1
1
|
/** The questions that cannot be skipped on the way to "ready". */
|
|
2
|
-
const ESSENTIAL = ['Is
|
|
3
|
-
/**
|
|
4
|
-
* The verdict, in a fixed order: what is broken first, then what cannot be believed, then what is
|
|
5
|
-
* missing, and only then "ready".
|
|
6
|
-
*/
|
|
2
|
+
const ESSENTIAL = ['Is everything passing?', 'Is the changed code tested?'];
|
|
7
3
|
export function decide(evidence) {
|
|
8
4
|
const checks = [];
|
|
9
5
|
// 1. Is anything failing right now? Nothing else matters while it is.
|
|
6
|
+
//
|
|
7
|
+
// Asked as "Is everything passing?" so that a yes is good news, like every other row. Phrased
|
|
8
|
+
// the other way round, the table said **no** to "Is anything failing?" when a test really was
|
|
9
|
+
// failing - true of the standing, backwards as an answer, and the table is the part a reviewer
|
|
10
|
+
// scans before anything else. The headline said Not ready and named the test either way; the
|
|
11
|
+
// row beside it still read like reassurance.
|
|
10
12
|
if (evidence.noHistory) {
|
|
11
|
-
checks.push({ question: 'Is
|
|
13
|
+
checks.push({ question: 'Is everything passing?', standing: 'not-checked', answer: 'no run has been kept for this project, so Tewip has not seen a test result here. Run `tewip explain <report>` in CI and it will know.', decidedBy: 'kept history' });
|
|
12
14
|
}
|
|
13
15
|
else if (evidence.failingNow.length > 0) {
|
|
14
16
|
checks.push({
|
|
15
|
-
question: 'Is
|
|
17
|
+
question: 'Is everything passing?',
|
|
16
18
|
standing: 'not-met',
|
|
17
19
|
answer: `${evidence.failingNow.length} test(s) touching this change failed in the last run: ${evidence.failingNow.slice(0, 3).map((f) => `${f.testId} (${f.category})`).join(', ')}`,
|
|
18
20
|
decidedBy: 'the triage rules and model, measured on real CI data',
|
|
19
21
|
});
|
|
20
22
|
}
|
|
21
23
|
else {
|
|
22
|
-
checks.push({ question: 'Is
|
|
24
|
+
checks.push({ question: 'Is everything passing?', standing: 'met', answer: 'nothing touching this change failed in the last kept run', decidedBy: 'kept history' });
|
|
23
25
|
}
|
|
24
26
|
// 2. Does anything test the change at all?
|
|
25
27
|
if (evidence.changedSource.length === 0) {
|
|
@@ -96,6 +98,19 @@ export function decide(evidence) {
|
|
|
96
98
|
decidedBy: 'tewip vouch, measured on 41,523 real tests in the six public projects in corpus/expected.json',
|
|
97
99
|
});
|
|
98
100
|
}
|
|
101
|
+
else if (evidence.couldNotJudge > 0 && evidence.couldNotJudge >= evidence.changedTests.length) {
|
|
102
|
+
// Nothing was established. `cannotFail` is empty because vouch declined every test in the
|
|
103
|
+
// change, not because it found them sound - three tests that call the function under test and
|
|
104
|
+
// assert nothing come back exactly like three good ones. Answering "yes" there is a clean
|
|
105
|
+
// answer produced by looking at nothing, in the question that matters most on a change somebody
|
|
106
|
+
// did not write by hand.
|
|
107
|
+
checks.push({
|
|
108
|
+
question: 'Can the tests in this change fail?',
|
|
109
|
+
standing: 'not-checked',
|
|
110
|
+
answer: `vouch would not judge any of the ${evidence.couldNotJudge} test(s) here: each body is one call to something it cannot follow, so whether they assert anything is unknown`,
|
|
111
|
+
decidedBy: 'tewip vouch',
|
|
112
|
+
});
|
|
113
|
+
}
|
|
99
114
|
else {
|
|
100
115
|
checks.push({
|
|
101
116
|
question: 'Can the tests in this change fail?',
|
|
@@ -116,6 +131,19 @@ export function decide(evidence) {
|
|
|
116
131
|
decidedBy: 'tewip prove, by running the tests against a broken copy of the code',
|
|
117
132
|
});
|
|
118
133
|
}
|
|
134
|
+
else if (evidence.proofsTried === 0) {
|
|
135
|
+
// Nothing was tried, so nothing was established. The reason is carried through rather than
|
|
136
|
+
// flattened into a shrug: "no unit test framework was found here" is a different thing for a
|
|
137
|
+
// reader to act on than "this costs minutes, ask for it".
|
|
138
|
+
checks.push({
|
|
139
|
+
question: 'Would the tests catch a bug in this code?',
|
|
140
|
+
standing: 'not-checked',
|
|
141
|
+
answer: evidence.proofSkipped
|
|
142
|
+
? `nothing could be proved here: ${evidence.proofSkipped}`
|
|
143
|
+
: 'nothing was proved here, so whether the tests would catch a bug is unknown',
|
|
144
|
+
decidedBy: 'tewip prove',
|
|
145
|
+
});
|
|
146
|
+
}
|
|
119
147
|
else {
|
|
120
148
|
checks.push({ question: 'Would the tests catch a bug in this code?', standing: 'met', answer: 'every deliberate change made to this code was caught by a covering test', decidedBy: 'tewip prove' });
|
|
121
149
|
}
|
|
@@ -147,6 +175,19 @@ export function decide(evidence) {
|
|
|
147
175
|
decidedBy: 'tewip trace, which counts a scenario covered only when a real test can be named',
|
|
148
176
|
});
|
|
149
177
|
}
|
|
178
|
+
else if (evidence.plansUnreadable > 0 || evidence.plansConsidered === 0) {
|
|
179
|
+
// An empty gap list means one of three things and only one of them is good news. A plan that
|
|
180
|
+
// would not parse was skipped in silence, and a project whose plans are all about other
|
|
181
|
+
// features has not had this question answered at all.
|
|
182
|
+
checks.push({
|
|
183
|
+
question: 'Is what was asked for covered?',
|
|
184
|
+
standing: 'not-checked',
|
|
185
|
+
answer: evidence.plansUnreadable > 0
|
|
186
|
+
? `${evidence.plansUnreadable} plan file(s) here could not be read, so their scenarios were never compared with this change`
|
|
187
|
+
: 'no plan this project keeps is about this change, so there was nothing to compare it with',
|
|
188
|
+
decidedBy: 'tewip trace',
|
|
189
|
+
});
|
|
190
|
+
}
|
|
150
191
|
else {
|
|
151
192
|
checks.push({ question: 'Is what was asked for covered?', standing: 'met', answer: 'every scenario this change relates to has a test that can be named', decidedBy: 'tewip trace' });
|
|
152
193
|
}
|
|
@@ -179,25 +220,6 @@ export function decide(evidence) {
|
|
|
179
220
|
],
|
|
180
221
|
};
|
|
181
222
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
const lines = [
|
|
186
|
-
`# Is this work done? ${title}`,
|
|
187
|
-
'',
|
|
188
|
-
`For \`${range}\`. ${decision.headline}.`,
|
|
189
|
-
'',
|
|
190
|
-
'| Question | Answer |',
|
|
191
|
-
'| --- | --- |',
|
|
192
|
-
...decision.checks.map((c) => `| ${c.question} | ${mark[c.standing]} |`),
|
|
193
|
-
'',
|
|
194
|
-
];
|
|
195
|
-
for (const check of decision.checks) {
|
|
196
|
-
if (check.standing === 'not-applicable')
|
|
197
|
-
continue;
|
|
198
|
-
const said = `${mark[check.standing] === '**no**' ? 'No' : mark[check.standing] === 'yes' ? 'Yes' : 'Not checked'}: ${check.answer}`;
|
|
199
|
-
lines.push(`### ${check.question}`, '', /[.!?]$/.test(said) ? said : `${said}.`, '', `_Decided by ${check.decidedBy}._`, '');
|
|
200
|
-
}
|
|
201
|
-
lines.push('## What this says nothing about', '', ...decision.notChecked.map((n) => `- ${n}`), '', '---', '', 'Nothing above is judged here: each answer comes from a layer with its own measured accuracy, and', 'this only puts them in a fixed order - what is broken, then what cannot be believed, then what is', 'missing. "Cannot say" is a real answer and is never rounded up to ready.');
|
|
202
|
-
return lines.join(String.fromCharCode(10));
|
|
203
|
-
}
|
|
223
|
+
// The report lives in render.ts; re-exported here so nothing that reads a verdict has to know
|
|
224
|
+
// where it is drawn.
|
|
225
|
+
export { renderDecision } from './render.js';
|
|
@@ -9,7 +9,7 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
9
9
|
import { join } from 'node:path';
|
|
10
10
|
import { CANNOT_FAIL, vouchFile } from './vouch.js';
|
|
11
11
|
export function vouchChanged(root, changed) {
|
|
12
|
-
const out = { files: 0, read: 0, cannotFail: [], crashOnly: [] };
|
|
12
|
+
const out = { files: 0, read: 0, cannotFail: [], crashOnly: [], couldNotJudge: [] };
|
|
13
13
|
for (const file of changed) {
|
|
14
14
|
const full = join(root, file);
|
|
15
15
|
if (!existsSync(full))
|
|
@@ -28,6 +28,7 @@ export function vouchChanged(root, changed) {
|
|
|
28
28
|
out.read += vouched.judgements.length;
|
|
29
29
|
out.cannotFail.push(...vouched.judgements.filter((j) => CANNOT_FAIL.includes(j.verdict)));
|
|
30
30
|
out.crashOnly.push(...vouched.judgements.filter((j) => j.verdict === 'crash-only'));
|
|
31
|
+
out.couldNotJudge.push(...vouched.judgements.filter((j) => j.verdict === 'cannot-tell'));
|
|
31
32
|
}
|
|
32
33
|
return out;
|
|
33
34
|
}
|
|
@@ -37,9 +37,22 @@ export function withoutComments(body) {
|
|
|
37
37
|
}
|
|
38
38
|
if (ch === '/' && next === '*') {
|
|
39
39
|
const close = body.indexOf('*/', i + 2);
|
|
40
|
-
// An unclosed
|
|
40
|
+
// Only when it closes. An unclosed one was treated as running to the end of the body, on the
|
|
41
|
+
// reasoning that a compiler would do the same - but a compiler is reading a language that has
|
|
42
|
+
// block comments, and this is handed every language. Ruby has none, and discourse writes
|
|
43
|
+
//
|
|
44
|
+
// %r{https://matrix.org/_matrix/client/r0/rooms/!blah/send/m.room.message/*}
|
|
45
|
+
//
|
|
46
|
+
// where the `/*` is the end of a regex literal. Running to the end of the body deleted the
|
|
47
|
+
// three assertions below it and reported two thoroughly checked tests as crash-only. A file
|
|
48
|
+
// that really ends inside a block comment does not compile; a regex containing `/*` is
|
|
49
|
+
// ordinary. The old regex required the closing `*/` and was right to.
|
|
50
|
+
if (close === -1) {
|
|
51
|
+
out += ch;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
41
54
|
out += ' ';
|
|
42
|
-
i = close
|
|
55
|
+
i = close + 1;
|
|
43
56
|
continue;
|
|
44
57
|
}
|
|
45
58
|
const atLineStart = i === 0 || /\s/.test(body[i - 1] ?? '');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tewip-ai",
|
|
3
|
-
"version": "0.70.
|
|
3
|
+
"version": "0.70.17",
|
|
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": [
|