tewip-ai 0.18.5 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,7 +7,7 @@ It works for every stack: Node.js (node:test, Jest, Vitest, Mocha), Python (pyte
7
7
  | Where | How |
8
8
  |---|---|
9
9
  | **Terminal** | `npm install -g tewip-ai`, then `tewip explain report.xml`, `tewip ci owner/repo --pr 12`, `tewip mcp` |
10
- | **VS Code, Cursor, Windsurf, VSCodium** | the Tewip extension: registers Tewip with the editor's AI assistant and adds *Explain a test report* and *Why did CI fail on this branch?* |
10
+ | **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 |
11
11
  | **JetBrains, Claude, other AI assistants** | add the MCP server `tewip mcp` (see [editor setup](#use-it-from-your-editor-any-ide-that-speaks-mcp)) |
12
12
  | **Any model** | OpenAI, Claude, Gemini, Azure, or a model on your own machine with Ollama (no account, nothing leaves the computer): `TEWIP_PROVIDER` |
13
13
  | **GitHub CI** | the Tewip Action: a comment on every pull request, an optional merge gate, verified fix PRs ([QUICKSTART](QUICKSTART.md)) |
@@ -96,6 +96,20 @@ The report reads the history cache of the branch it runs on, usually the default
96
96
 
97
97
  The repository needs "Allow GitHub Actions to create and approve pull requests" enabled for fix PRs. Pull requests opened by the workflow token don't trigger CI themselves; the agent verified each change by re-running the test before opening the PR. See [triage-agent-sandbox](https://github.com/hmamut39/triage-agent-sandbox) for a working example. If this repository is private, other repositories can only use the action when Settings → Actions → Access allows it.
98
98
 
99
+ ### The editor extension (VS Code, Cursor, Windsurf, VSCodium)
100
+
101
+ Download **[tewip-0.5.0.vsix](https://github.com/hmamut39/tewip/releases/tag/vscode-0.5.0)**, then in the
102
+ editor press `Ctrl+Shift+P` and run **Extensions: Install from VSIX…**. It needs the command line:
103
+ `npm install -g tewip-ai`.
104
+
105
+ Press `Ctrl+Shift+P` and type "Tewip" for: *Explain a test report…*, *Why did CI fail on this
106
+ branch?*, *Risk review of this change*, *Which flaky tests to quarantine*, *Check secrets and
107
+ dependencies*, and *What our corrections add up to*. The editor's AI assistant also picks Tewip up
108
+ as an MCP server with nothing to configure.
109
+
110
+ It is not in the Visual Studio Marketplace: publishing there now requires an Azure subscription,
111
+ which means a credit card for a free extension. The file above installs exactly the same thing.
112
+
99
113
  ### 2. From any AI assistant, through MCP (the whole IT team)
100
114
 
101
115
  `mcp/server.ts` is a read-only [Model Context Protocol](https://modelcontextprotocol.io) server. Anyone on the team can ask their own assistant about test failures, in the editor or chat they already use:
package/dist/cli/help.js CHANGED
@@ -100,6 +100,11 @@ Usage:
100
100
  same way more than once, which rule decided it, and the failures
101
101
  it came from. Says "not yet" rather than inventing a pattern
102
102
  from two cases. No model, no cost.
103
+ tewip attest [--run <id>] [--out attestation.md] [--json bundle.json]
104
+ What Tewip checked for a build, as one artifact: the claims with
105
+ their evidence and which layer decided each, what it refused to
106
+ decide and why, and what it did not check at all. Nothing enters
107
+ it that Tewip did not verify itself. No model, no cost.
103
108
  tewip mcp Serve Tewip over MCP (stdio) for an editor or AI assistant.
104
109
  tewip --version Print the version.
105
110
 
package/dist/cli/tewip.js CHANGED
@@ -177,6 +177,11 @@ async function main() {
177
177
  runLearn({ root: process.cwd(), name: flag(args, 'name'), labels: flag(args, 'labels'), out: flag(args, 'out') });
178
178
  return;
179
179
  }
180
+ case 'attest': {
181
+ const { runAttest } = await import('../engine/attest/attest-command.js');
182
+ runAttest({ root: process.cwd(), name: flag(args, 'name'), runId: flag(args, 'run'), out: flag(args, 'out'), json: flag(args, 'json') });
183
+ return;
184
+ }
180
185
  case 'mcp':
181
186
  // An editor starts this from any folder, and an installed tool's own folder may be read-only:
182
187
  // the AI answer cache goes to the user's home folder, which is always writable.
@@ -0,0 +1,63 @@
1
+ // `tewip attest`: the evidence for one run, as an artifact a build can carry.
2
+ import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { datasetFiles, readJsonl, readTriageRecords } from '../../reporters/store.js';
5
+ import { ENGINE_VERSION } from '../engine.js';
6
+ import { attestRun, renderAttestation } from './attest.js';
7
+ /** Everything Tewip kept for one project, newest run last. */
8
+ function history(root, only) {
9
+ const dir = join(root, '.tewip', 'history');
10
+ const projects = existsSync(dir) ? (only ? [only] : readdirSync(dir)) : [];
11
+ for (const project of projects) {
12
+ const files = datasetFiles(join(dir, project));
13
+ const runs = readJsonl(files.runs);
14
+ if (runs.length > 0)
15
+ return { project, runs, records: readTriageRecords(files.triage) };
16
+ }
17
+ return { project: only ?? 'this project', runs: [], records: [] };
18
+ }
19
+ /**
20
+ * What ran, in the words of the thing that ran. Only what is recorded counts: an attestation that
21
+ * listed checks Tewip merely supports would be the fabrication this whole phase exists to prevent.
22
+ */
23
+ function whatWasChecked(records) {
24
+ const checked = [];
25
+ const notChecked = [];
26
+ checked.push('every failing test in the run was read, and each was given a cause or left undecided');
27
+ const byRules = records.filter((r) => r.diagnosis?.source === 'rules').length;
28
+ const byModel = records.filter((r) => r.diagnosis?.source === 'llm').length;
29
+ if (byRules > 0)
30
+ checked.push(`${byRules} decided by deterministic rules (${ENGINE_VERSION}), which call no model and give the same answer every time`);
31
+ if (byModel > 0)
32
+ checked.push(`${byModel} sent to the model, because no rule could settle them`);
33
+ // What Tewip is silent about unless it was asked, and therefore must say it did not do.
34
+ notChecked.push('the application itself: no security scan, dependency audit or web check is part of this bundle unless `tewip secure` ran and is quoted here');
35
+ notChecked.push('tests that passed: a passing test is counted, not examined');
36
+ notChecked.push('anything no test covers, which no test report can show');
37
+ return { checked, notChecked };
38
+ }
39
+ export function runAttest(cmd) {
40
+ const { project, runs, records } = history(cmd.root, cmd.name);
41
+ if (runs.length === 0) {
42
+ throw new Error('nothing to attest: no run has been kept here yet. Run `tewip explain <report>` first, so there is evidence to attest to.');
43
+ }
44
+ const run = cmd.runId ? runs.find((r) => r.runId === cmd.runId) : runs[runs.length - 1];
45
+ if (!run)
46
+ throw new Error(`no run called ${cmd.runId} was kept here`);
47
+ const mine = records.filter((r) => r.runId === run.runId);
48
+ const { checked, notChecked } = whatWasChecked(mine);
49
+ const attestation = attestRun(project, run, mine, { checked, notChecked, tool: 'tewip', engine: ENGINE_VERSION });
50
+ const markdown = renderAttestation(attestation);
51
+ console.log(markdown);
52
+ if (cmd.out) {
53
+ mkdirSync(dirname(resolve(cmd.out)), { recursive: true });
54
+ writeFileSync(resolve(cmd.out), `${markdown}\n`, 'utf8');
55
+ console.error(`\nWritten to ${cmd.out}`);
56
+ }
57
+ if (cmd.json) {
58
+ mkdirSync(dirname(resolve(cmd.json)), { recursive: true });
59
+ writeFileSync(resolve(cmd.json), `${JSON.stringify(attestation, null, 2)}\n`, 'utf8');
60
+ console.error(`Machine-readable bundle written to ${cmd.json}`);
61
+ }
62
+ return attestation;
63
+ }
@@ -0,0 +1,89 @@
1
+ const sourceOf = (record) => String(record.diagnosis?.source ?? '');
2
+ /** The evidence line a person reads: the first thing the deciding layer actually said. */
3
+ function evidenceOf(record) {
4
+ const evidence = record.diagnosis?.evidence ?? [];
5
+ const first = evidence.find((e) => e.message)?.message ?? '';
6
+ return first.slice(0, 300);
7
+ }
8
+ /**
9
+ * The bundle for one run. `records` and `run` are what Tewip itself wrote when it read the report,
10
+ * so everything here is traceable; nothing is inferred and nothing is re-judged.
11
+ */
12
+ export function attestRun(project, run, records, { checked, notChecked, tool, engine }) {
13
+ const claims = [];
14
+ const refusals = [];
15
+ for (const record of records) {
16
+ const diagnosis = record.diagnosis;
17
+ const category = diagnosis?.category ?? 'UNKNOWN';
18
+ const decidedBy = sourceOf(record);
19
+ // Undecided is not a claim. It is the refusal that makes the rest of the bundle believable.
20
+ if (category === 'UNKNOWN' || !decidedBy) {
21
+ refusals.push({
22
+ what: `${record.testId} failed, and Tewip did not classify it`,
23
+ why: evidenceOf(record) || 'no rule matched and the model was not asked or did not answer',
24
+ runId: record.runId,
25
+ });
26
+ continue;
27
+ }
28
+ claims.push({
29
+ claim: `${record.testId} failed because of ${category}`,
30
+ evidence: evidenceOf(record) || 'no evidence recorded',
31
+ runId: record.runId,
32
+ decidedBy,
33
+ confidence: Number(diagnosis?.confidence ?? 0),
34
+ });
35
+ }
36
+ return {
37
+ project,
38
+ commit: run.gitSha ?? '',
39
+ runId: run.runId,
40
+ startedAt: run.startedAt,
41
+ finishedAt: run.finishedAt,
42
+ testsAttempted: Object.keys(run.attempts ?? {}).length,
43
+ failures: records.length,
44
+ checked,
45
+ notChecked,
46
+ claims,
47
+ refusals,
48
+ producedBy: { tool, engine, at: new Date().toISOString() },
49
+ };
50
+ }
51
+ /** The bundle as a person reads it. The refusals are not hidden at the bottom; they are the point. */
52
+ export function renderAttestation(a) {
53
+ const when = a.finishedAt ? a.finishedAt.slice(0, 19).replace('T', ' ') : 'unknown';
54
+ const lines = [
55
+ `# What Tewip checked: ${a.project}`,
56
+ '',
57
+ `Run \`${a.runId}\`${a.commit ? ` at commit \`${a.commit.slice(0, 8)}\`` : ''}, finished ${when} UTC.`,
58
+ `${a.testsAttempted} test(s) attempted, ${a.failures} failed.`,
59
+ '',
60
+ '## What was checked',
61
+ '',
62
+ ...(a.checked.length > 0 ? a.checked.map((c) => `- ${c}`) : ['- nothing: this bundle makes no claims']),
63
+ '',
64
+ '## What was NOT checked',
65
+ '',
66
+ ...(a.notChecked.length > 0
67
+ ? a.notChecked.map((c) => `- ${c}`)
68
+ : ['- nothing is recorded as unchecked, which is itself worth doubting: a check that was never run cannot report itself']),
69
+ '',
70
+ ];
71
+ lines.push(`## Claims (${a.claims.length})`, '');
72
+ if (a.claims.length === 0)
73
+ lines.push('None: nothing failed, so there is nothing to explain.', '');
74
+ for (const claim of a.claims) {
75
+ lines.push(`- **${claim.claim}** — ${claim.decidedBy}, confidence ${claim.confidence.toFixed(2)}`);
76
+ lines.push(` - Evidence: ${claim.evidence}`);
77
+ lines.push(` - From run \`${claim.runId}\``);
78
+ }
79
+ lines.push('', `## Refused (${a.refusals.length})`, '');
80
+ lines.push(a.refusals.length > 0
81
+ ? 'Tewip could not decide these. They are listed because a tool that never says so cannot be trusted about the rest.'
82
+ : 'Nothing was left undecided in this run.', '');
83
+ for (const refusal of a.refusals) {
84
+ lines.push(`- **${refusal.what}**`);
85
+ lines.push(` - Why: ${refusal.why}`);
86
+ }
87
+ lines.push('', '---', '', `Produced by ${a.producedBy.tool} (${a.producedBy.engine}) at ${a.producedBy.at.slice(0, 19).replace('T', ' ')} UTC.`, 'Every claim above names the run it came from and the layer that decided it. This is a statement', 'about the checks listed under "what was checked" and nothing else.');
88
+ return lines.join('\n');
89
+ }
@@ -0,0 +1,42 @@
1
+ /** The path a web finding says is being served, e.g. "/.env" from "GET http://host/.env answered". */
2
+ function servedPath(item) {
3
+ if (item.kind !== 'web')
4
+ return null;
5
+ // Take the whole address and ask the URL parser for the path. Pulling the path out with a regular
6
+ // expression captured "//127.0.0.1:3211/.env", host and all, and then matched nothing.
7
+ const found = /GET\s+(\S+)\s+answered/.exec(item.why);
8
+ if (!found)
9
+ return null;
10
+ try {
11
+ return new URL(found[1]).pathname;
12
+ }
13
+ catch {
14
+ return found[1].startsWith('/') ? found[1] : null;
15
+ }
16
+ }
17
+ /** The file a secret finding is in, from "a database URL with a password in it in .env:1". */
18
+ const fileOf = (item) => (item.kind === 'secret' ? (item.where.split(':')[0] ?? '') : '');
19
+ /**
20
+ * Where one check changes what another means. Today that is one case, and it is the one that
21
+ * matters most: a secret sitting in a file the application serves.
22
+ */
23
+ export function reconcile(items) {
24
+ const served = new Set(items
25
+ .map(servedPath)
26
+ .filter((p) => Boolean(p))
27
+ .map((p) => p.replace(/^\//, '').toLowerCase()));
28
+ if (served.size === 0)
29
+ return items;
30
+ return items.map((item) => {
31
+ const file = fileOf(item).replace(/\\/g, '/').toLowerCase();
32
+ if (!file || !served.has(file) || item.verdict === 'accepted')
33
+ return item;
34
+ return {
35
+ ...item,
36
+ verdict: 'real-risk',
37
+ severity: 'critical',
38
+ why: `${item.why} - but the running application serves /${file} to anyone who asks, so this credential is public`,
39
+ next: `treat this credential as leaked and rotate it now, then stop serving /${file}`,
40
+ };
41
+ });
42
+ }
@@ -9,6 +9,7 @@ import { auditDependencies } from './deps.js';
9
9
  import { scanSecrets, sourceFiles } from './secrets.js';
10
10
  import { readAccepted, triageDependency, triageSecret, triageWeb, worstFirst } from './triage.js';
11
11
  import { isLocal, scanWeb } from './web.js';
12
+ import { reconcile } from './reconcile.js';
12
13
  import { loadVerdicts, saidByAPerson, verdictFor } from '../learn/verdicts.js';
13
14
  import { readZapReport, zapAvailable, zapInstructions } from './zap.js';
14
15
  const CODE = /\.(?:[cm]?[jt]sx?|py|rb|go|java|kt|cs|php|rs|vue|svelte)$/;
@@ -98,6 +99,11 @@ export async function runSecure(cmd) {
98
99
  else if (cmd.url) {
99
100
  notChecked.push(`a full web scan. Tewip's own check is passive; for the deep one: ${zapInstructions(cmd.url, zapAvailable(root))}`);
100
101
  }
102
+ // One check can change what another means: a secret in a file the application serves is not
103
+ // "never left the machine", whatever git says about it.
104
+ const joined = reconcile(items);
105
+ items.length = 0;
106
+ items.push(...joined);
101
107
  // A person's decision about a finding wins over Tewip's, and says whose it was.
102
108
  const corrections = loadVerdicts(root);
103
109
  const decided = items.map((item) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tewip-ai",
3
- "version": "0.18.5",
3
+ "version": "0.19.0",
4
4
  "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.",
5
5
  "keywords": [
6
6
  "test-failures",