tewip-ai 0.4.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 +202 -0
- package/QUICKSTART.md +178 -0
- package/README.md +308 -0
- package/TEAM-GUIDE.md +234 -0
- package/dist/cli/tewip.js +117 -0
- package/dist/engine/autofix/autofix.js +63 -0
- package/dist/engine/autofix/candidates.js +33 -0
- package/dist/engine/autofix/locate.js +78 -0
- package/dist/engine/corrections.js +66 -0
- package/dist/engine/dataset.js +41 -0
- package/dist/engine/decide.js +130 -0
- package/dist/engine/device-errors.js +45 -0
- package/dist/engine/drift-rules.js +98 -0
- package/dist/engine/driver-errors.js +103 -0
- package/dist/engine/engine.js +76 -0
- package/dist/engine/error-parse.js +150 -0
- package/dist/engine/history.js +105 -0
- package/dist/engine/import/blob.js +165 -0
- package/dist/engine/import/junit.js +235 -0
- package/dist/engine/import/xml.js +104 -0
- package/dist/engine/llm/anthropic.js +59 -0
- package/dist/engine/llm/client.js +139 -0
- package/dist/engine/llm/context.js +141 -0
- package/dist/engine/llm/llm-diagnose.js +48 -0
- package/dist/engine/llm/prompt.js +40 -0
- package/dist/engine/llm/providers.js +54 -0
- package/dist/engine/llm/usage.js +69 -0
- package/dist/engine/pipeline.js +33 -0
- package/dist/engine/quality/frameworks.js +181 -0
- package/dist/engine/quality/grounding.js +203 -0
- package/dist/engine/quality/model.js +17 -0
- package/dist/engine/quality/plan-command.js +34 -0
- package/dist/engine/quality/plan-render.js +38 -0
- package/dist/engine/quality/plan.js +119 -0
- package/dist/engine/quality/write-command.js +146 -0
- package/dist/engine/quality/write-verify.js +140 -0
- package/dist/engine/quality/write.js +144 -0
- package/dist/engine/rules.js +174 -0
- package/dist/engine/signature.js +23 -0
- package/dist/engine/similarity.js +76 -0
- package/dist/engine/snapshot.js +56 -0
- package/dist/engine/timing-rules.js +188 -0
- package/dist/engine/trace/dom.js +92 -0
- package/dist/engine/trace/trace-summary.js +87 -0
- package/dist/engine/trace/zip.js +53 -0
- package/dist/eval/harvest/gh.js +21 -0
- package/dist/eval/harvest/harvest.js +117 -0
- package/dist/mcp/github-report.js +50 -0
- package/dist/mcp/server.js +80 -0
- package/dist/mcp/tools.js +124 -0
- package/dist/outlets/design-debt.js +22 -0
- package/dist/outlets/items.js +94 -0
- package/dist/outlets/view.js +63 -0
- package/dist/outlets/views/architecture.js +77 -0
- package/dist/outlets/views/developer.js +59 -0
- package/dist/outlets/views/digest.js +66 -0
- package/dist/outlets/views/infra.js +49 -0
- package/dist/outlets/views/quality.js +108 -0
- package/dist/outlets/views/release.js +33 -0
- package/dist/outlets/views/sdet.js +29 -0
- package/dist/reporters/run-context.js +20 -0
- package/dist/reporters/store.js +46 -0
- package/dist/reporters/triage-reporter.js +101 -0
- package/dist/reporters/types.js +4 -0
- package/dist/scripts/explain.js +58 -0
- package/package.json +81 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Harvests every finished Playwright CI run (passes and failures) of a public GitHub repo in
|
|
2
|
+
// a date window into data/real/<source>/ (gitignored): triage.jsonl + runs.jsonl in the
|
|
3
|
+
// reporter's format, attachments, and runs-meta.jsonl with GitHub facts for labelling.
|
|
4
|
+
// Passing runs matter: without them every test's history would look like "always fails".
|
|
5
|
+
// Usage: node eval/harvest/harvest.ts <source> --from YYYY-MM-DD --to YYYY-MM-DD [--dataset name] [--max-runs N] [--concurrency 4]
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { appendJsonl, datasetFiles, readJsonl } from '../../reporters/store.js';
|
|
8
|
+
import { importBlobArtifacts } from '../../engine/import/blob.js';
|
|
9
|
+
import { downloadArtifact, ghApi } from './gh.js';
|
|
10
|
+
/** artifact: name prefix; every live artifact of a run starting with it is merged into that run. */
|
|
11
|
+
export const SOURCES = {
|
|
12
|
+
gutenberg: { repo: 'WordPress/gutenberg', workflow: 'end2end-test.yml', artifact: 'blob-reports-merged' },
|
|
13
|
+
// Supabase Studio: one blob-report-next-N artifact per shard (the TanStack build runs the same tests and is left out).
|
|
14
|
+
supabase: { repo: 'supabase/supabase', workflow: 'studio-e2e-test.yml', artifact: 'blob-report-next-' },
|
|
15
|
+
// Penpot integration tests: one integration-blob-report-N per shard, kept a few days.
|
|
16
|
+
penpot: { repo: 'penpot/penpot', workflow: 'tests-integration.yml', artifact: 'integration-blob-report-' },
|
|
17
|
+
// Ghost uploads blob reports for failed runs only, so its history has no passing runs.
|
|
18
|
+
ghost: { repo: 'TryGhost/Ghost', workflow: 'ci.yml', artifact: 'blob-report-Main-' },
|
|
19
|
+
// JupyterLab's Galata UI tests: every run merges its shards into one artifact.
|
|
20
|
+
jupyterlab: { repo: 'jupyterlab/jupyterlab', workflow: 'galata.yml', artifact: 'galata-blobs-merged' },
|
|
21
|
+
// LXD UI: one artifact per browser and cluster mode.
|
|
22
|
+
lxdui: { repo: 'canonical/lxd-ui', workflow: 'pr.yaml', artifact: 'test-blob-report-' },
|
|
23
|
+
// Nextcloud server's Playwright suite.
|
|
24
|
+
nextcloud: { repo: 'nextcloud/server', workflow: 'playwright.yml', artifact: 'blob-report' },
|
|
25
|
+
};
|
|
26
|
+
const flag = (name, fallback) => {
|
|
27
|
+
const i = process.argv.indexOf(`--${name}`);
|
|
28
|
+
const value = i > 0 ? process.argv[i + 1] : undefined;
|
|
29
|
+
if (value === undefined && fallback === undefined)
|
|
30
|
+
throw new Error(`missing --${name}`);
|
|
31
|
+
return value ?? fallback;
|
|
32
|
+
};
|
|
33
|
+
/** runs-meta.jsonl of a dataset under data/real/ (named after the source unless --dataset is given). */
|
|
34
|
+
export const metaFile = (dataset) => join(datasetFiles(`data/real/${dataset}`).dir, 'runs-meta.jsonl');
|
|
35
|
+
async function listRuns(repo, workflow, from, to) {
|
|
36
|
+
const runs = [];
|
|
37
|
+
for (const conclusion of ['success', 'failure']) {
|
|
38
|
+
for (let page = 1; page <= 10; page++) {
|
|
39
|
+
const { workflow_runs } = await ghApi(`repos/${repo}/actions/workflows/${workflow}/runs?status=${conclusion}&created=${from}..${to}&per_page=100&page=${page}`);
|
|
40
|
+
runs.push(...workflow_runs);
|
|
41
|
+
if (workflow_runs.length < 100)
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return runs.sort((a, b) => a.created_at.localeCompare(b.created_at));
|
|
46
|
+
}
|
|
47
|
+
async function main() {
|
|
48
|
+
const source = process.argv[2];
|
|
49
|
+
const config = SOURCES[source];
|
|
50
|
+
if (!config)
|
|
51
|
+
throw new Error(`usage: harvest.ts <${Object.keys(SOURCES).join('|')}> --from YYYY-MM-DD --to YYYY-MM-DD`);
|
|
52
|
+
const from = flag('from');
|
|
53
|
+
const to = flag('to');
|
|
54
|
+
const maxRuns = Number(flag('max-runs', '1000'));
|
|
55
|
+
const concurrency = Number(flag('concurrency', '4'));
|
|
56
|
+
// A separate dataset name keeps e.g. a held-out window apart from the data used for tuning.
|
|
57
|
+
const dataset = flag('dataset', source);
|
|
58
|
+
const files = datasetFiles(`data/real/${dataset}`);
|
|
59
|
+
// Only successful imports count as done; skipped runs are retried on the next harvest.
|
|
60
|
+
const known = new Set(readJsonl(metaFile(dataset)).filter((m) => m.imported).map((m) => m.runId));
|
|
61
|
+
const runs = (await listRuns(config.repo, config.workflow, from, to))
|
|
62
|
+
.filter((r) => !known.has(`${source}-${r.id}-a${r.run_attempt}`))
|
|
63
|
+
.slice(0, maxRuns);
|
|
64
|
+
console.log(`[harvest] ${runs.length} run(s) to import (${runs.filter((r) => r.conclusion === 'failure').length} failed)`);
|
|
65
|
+
let done = 0;
|
|
66
|
+
let records = 0;
|
|
67
|
+
const importOne = async (run) => {
|
|
68
|
+
const runId = `${source}-${run.id}-a${run.run_attempt}`;
|
|
69
|
+
const meta = {
|
|
70
|
+
runId,
|
|
71
|
+
githubRunId: run.id,
|
|
72
|
+
attempt: run.run_attempt,
|
|
73
|
+
sha: run.head_sha,
|
|
74
|
+
branch: run.head_branch,
|
|
75
|
+
event: run.event,
|
|
76
|
+
prNumbers: run.pull_requests.map((p) => p.number),
|
|
77
|
+
createdAt: run.created_at,
|
|
78
|
+
conclusion: run.conclusion,
|
|
79
|
+
imported: false,
|
|
80
|
+
};
|
|
81
|
+
try {
|
|
82
|
+
const { artifacts } = await ghApi(`repos/${config.repo}/actions/runs/${run.id}/artifacts?per_page=100`);
|
|
83
|
+
const matching = artifacts.filter((a) => a.name.startsWith(config.artifact) && !a.expired);
|
|
84
|
+
if (matching.length === 0) {
|
|
85
|
+
appendJsonl(metaFile(dataset), [{ ...meta, skipReason: `no live ${config.artifact} artifact` }]);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const zips = [];
|
|
89
|
+
for (const a of matching)
|
|
90
|
+
zips.push([a.name, await downloadArtifact(config.repo, a.id)]);
|
|
91
|
+
const result = importBlobArtifacts(zips, { runId, gitSha: run.head_sha, branch: run.head_branch }, join(files.dir, 'files', String(run.id)));
|
|
92
|
+
appendJsonl(files.triage, result.records);
|
|
93
|
+
appendJsonl(files.runs, [result.summary]);
|
|
94
|
+
appendJsonl(metaFile(dataset), [
|
|
95
|
+
{ ...meta, imported: true, failureRecords: result.records.length, ...(result.skippedShards.length ? { skipReason: `corrupt shard(s): ${result.skippedShards.join(', ')}` } : {}) },
|
|
96
|
+
]);
|
|
97
|
+
records += result.records.length;
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
appendJsonl(metaFile(dataset), [{ ...meta, skipReason: `import failed: ${String(error).slice(0, 200)}` }]);
|
|
101
|
+
console.warn(`[harvest] ${runId} skipped: ${String(error).slice(0, 200)}`);
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
done++;
|
|
105
|
+
if (done % 20 === 0 || done === runs.length)
|
|
106
|
+
console.log(`[harvest] ${done}/${runs.length} runs processed, ${records} failure record(s) so far`);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
let next = 0;
|
|
110
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, runs.length) }, async () => {
|
|
111
|
+
while (next < runs.length)
|
|
112
|
+
await importOne(runs[next++]);
|
|
113
|
+
}));
|
|
114
|
+
console.log(`[harvest] ${source}: done, ${records} failure record(s) into ${files.dir}`);
|
|
115
|
+
}
|
|
116
|
+
if (import.meta.main)
|
|
117
|
+
await main();
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Fetches the latest Tewip report that the GitHub Action uploaded for a repository,
|
|
2
|
+
// pull request or branch, so an assistant can answer "why did CI fail on my PR?" without
|
|
3
|
+
// anyone opening the Actions page. Uses the gh CLI's login; read-only.
|
|
4
|
+
import { readZipBuffer } from '../engine/trace/zip.js';
|
|
5
|
+
import { downloadArtifact, ghApi } from '../eval/harvest/gh.js';
|
|
6
|
+
import { cap } from './tools.js';
|
|
7
|
+
/** "tewip-report" or "tewip-report-<suite>"; "triage-agent-report" is what versions before the rename uploaded. */
|
|
8
|
+
const REPORT = /^(?:tewip|triage-agent)-report(-.+)?$/;
|
|
9
|
+
/** How many recent runs to look through for one that uploaded a report. */
|
|
10
|
+
const RUNS_TO_SEARCH = 30;
|
|
11
|
+
/**
|
|
12
|
+
* The newest workflow run on the branch that uploaded Tewip reports, and those reports.
|
|
13
|
+
* Looked up run by run rather than across all of a repository's artifacts, which in a busy
|
|
14
|
+
* repository are mostly other workflows' and would push the report out of the first page.
|
|
15
|
+
*/
|
|
16
|
+
async function latestReports(repo, branch) {
|
|
17
|
+
const query = `per_page=${RUNS_TO_SEARCH}${branch ? `&branch=${encodeURIComponent(branch)}` : ''}`;
|
|
18
|
+
const { workflow_runs: runs } = await ghApi(`repos/${repo}/actions/runs?${query}`);
|
|
19
|
+
for (const run of runs) {
|
|
20
|
+
const { artifacts } = await ghApi(`repos/${repo}/actions/runs/${run.id}/artifacts?per_page=100`);
|
|
21
|
+
const reports = artifacts.filter((a) => REPORT.test(a.name) && !a.expired);
|
|
22
|
+
if (reports.length > 0)
|
|
23
|
+
return { run, artifacts: reports.sort((a, b) => a.name.localeCompare(b.name)) };
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
export async function githubCiReport(repo, pr, branch) {
|
|
28
|
+
const head = pr ? (await ghApi(`repos/${repo}/pulls/${pr}`)).head.ref : branch;
|
|
29
|
+
const target = pr ? `PR #${pr} (${head})` : head ? `branch ${head}` : 'any branch';
|
|
30
|
+
const found = await latestReports(repo, head);
|
|
31
|
+
if (!found)
|
|
32
|
+
return `No Tewip report for ${repo}, ${target} in its last ${RUNS_TO_SEARCH} workflow runs. Is the Tewip action installed in that repository's CI workflow, and has it run since?`;
|
|
33
|
+
const sections = [];
|
|
34
|
+
for (const artifact of found.artifacts) {
|
|
35
|
+
const files = readZipBuffer(await downloadArtifact(repo, artifact.id), (n) => n.endsWith('.md') || n === 'triage.json');
|
|
36
|
+
const text = (name) => files.get(name)?.toString('utf8') ?? '';
|
|
37
|
+
const bundle = JSON.parse(text('triage.json') || '{}');
|
|
38
|
+
const envCount = (bundle.items ?? []).filter((i) => i.diagnosis.category === 'ENV').length;
|
|
39
|
+
const suite = artifact.name.replace(/^(?:tewip|triage-agent)-report-?/, '');
|
|
40
|
+
sections.push([
|
|
41
|
+
`# Tewip CI report: ${repo}, ${target}${suite ? ` - suite "${suite}"` : ''}`,
|
|
42
|
+
`Run: ${bundle.runUrl ?? found.run.html_url} · commit \`${(bundle.sha ?? found.run.head_sha).slice(0, 8)}\` · generated ${bundle.generatedAt ?? artifact.created_at}${bundle.fixPr ? ` · verified fix PR: ${bundle.fixPr}` : ''}`,
|
|
43
|
+
text('pr-comment.md'),
|
|
44
|
+
envCount > 0 ? text('infra.md') : '',
|
|
45
|
+
]
|
|
46
|
+
.filter(Boolean)
|
|
47
|
+
.join('\n\n'));
|
|
48
|
+
}
|
|
49
|
+
return cap(sections.join('\n\n---\n\n'));
|
|
50
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// MCP server for Tewip: lets any MCP-capable AI assistant (Claude Code, Claude Desktop,
|
|
3
|
+
// Cursor, VS Code agent mode, ...) ask about Playwright test failures and get the same
|
|
4
|
+
// diagnoses, next actions and role views the agent produces in CI.
|
|
5
|
+
// Read-only by design, except test_plan, which saves the plan it returns in the project's .tewip
|
|
6
|
+
// folder: no tool changes code, CI, GitHub or collected data.
|
|
7
|
+
// Run: node mcp/server.ts (stdio). Setup for each client is in README.md.
|
|
8
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
9
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
10
|
+
import { existsSync } from 'node:fs';
|
|
11
|
+
import { isAbsolute } from 'node:path';
|
|
12
|
+
import { z } from 'zod';
|
|
13
|
+
import { githubCiReport } from './github-report.js';
|
|
14
|
+
import { explainFailure, listDatasets, listFailedRuns, runView, windowView } from './tools.js';
|
|
15
|
+
const server = new McpServer({ name: 'tewip', version: '0.4.0' });
|
|
16
|
+
const readOnly = { readOnlyHint: true, destructiveHint: false, idempotentHint: true };
|
|
17
|
+
const dataset = z.string().optional().describe('Dataset directory relative to the Tewip repo, e.g. "data/real/gutenberg-holdout". Default: data/ (what the reporter collected). See list_datasets.');
|
|
18
|
+
const run = z.string().optional().describe('Run id or a unique part of it. Default: the latest run with failures.');
|
|
19
|
+
const ai = z.boolean().optional().describe('Use the AI layer for failures the rules cannot decide (default true; needs OPENAI_API_KEY).');
|
|
20
|
+
async function answer(fn) {
|
|
21
|
+
try {
|
|
22
|
+
return { content: [{ type: 'text', text: await fn() }] };
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
return { content: [{ type: 'text', text: `Tewip: ${error instanceof Error ? error.message : String(error)}` }], isError: true };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
server.registerTool('github_ci_report', {
|
|
29
|
+
title: 'CI diagnosis for a pull request',
|
|
30
|
+
description: "Latest Tewip report from a GitHub repository's CI: which failing tests the change likely caused, why each failed, the next action, and any verified fix PR. Use for questions like \"why did CI fail on my PR?\".",
|
|
31
|
+
inputSchema: {
|
|
32
|
+
repo: z.string().describe('owner/name, e.g. hmamut39/triage-agent-sandbox'),
|
|
33
|
+
pr: z.number().int().optional().describe('Pull request number'),
|
|
34
|
+
branch: z.string().optional().describe('Branch name, when there is no PR'),
|
|
35
|
+
},
|
|
36
|
+
annotations: { ...readOnly, openWorldHint: true },
|
|
37
|
+
}, ({ repo, pr, branch }) => answer(() => githubCiReport(repo, pr, branch)));
|
|
38
|
+
server.registerTool('list_datasets', { title: 'List datasets', description: 'Collected failure datasets on this machine, with size and latest run.', inputSchema: {}, annotations: readOnly }, () => answer(listDatasets));
|
|
39
|
+
server.registerTool('list_failed_runs', { title: 'List failed runs', description: 'Latest runs with failing tests: run id, time, branch and number of failing tests.', inputSchema: { dataset, limit: z.number().int().min(1).max(100).optional() }, annotations: readOnly }, ({ dataset: d, limit }) => answer(() => listFailedRuns(d, limit)));
|
|
40
|
+
const runViews = [
|
|
41
|
+
['triage_board', 'sdet', 'SDET triage board', 'Every failing test of a run, grouped by next action (fix locator, stabilize, fix data, file bug, re-run environment, escalate), with confidence and evidence.'],
|
|
42
|
+
['pr_impact', 'developer', 'Is this failure caused by the change?', 'Developer view of a run: which failures the change likely caused and which it probably did not, with the reason.'],
|
|
43
|
+
['environment_report', 'environment', 'Environment failures', 'DevOps / SRE view of a run: environment failures grouped by the failing dependency, retry recovery, and one grouped alert text.'],
|
|
44
|
+
['release_readiness', 'release', 'Release readiness', 'Business / UAT view of a run in plain words: READY, WAIT or NOT READY, and which product areas are broken.'],
|
|
45
|
+
];
|
|
46
|
+
for (const [name, view, title, description] of runViews) {
|
|
47
|
+
server.registerTool(name, { title, description, inputSchema: { dataset, run, ai }, annotations: readOnly }, ({ dataset: d, run: r, ai: a }) => answer(() => runView(view, d, r, a ?? true)));
|
|
48
|
+
}
|
|
49
|
+
const windowViews = [
|
|
50
|
+
['quality_report', 'quality', 'Engineering quality', 'Senior / principal engineer view over the whole dataset: flakiness hotspots, recurring root-cause patterns, test-design debt ranked by CI time lost.'],
|
|
51
|
+
['architecture_report', 'architecture', 'Architecture fragility', 'Architect view: fragile areas (main-branch bugs vs regressions caught in PRs), single commits that broke many suites, failing service dependencies.'],
|
|
52
|
+
['risk_digest', 'digest', 'Risk digest', 'Lead / manager view: product areas whose failure rate grew between the first and second half of the data window, on the main branch.'],
|
|
53
|
+
];
|
|
54
|
+
for (const [name, view, title, description] of windowViews) {
|
|
55
|
+
server.registerTool(name, { title, description, inputSchema: { dataset, ai }, annotations: readOnly }, ({ dataset: d, ai: a }) => answer(() => windowView(view, d, a ?? true)));
|
|
56
|
+
}
|
|
57
|
+
server.registerTool('explain_failure', {
|
|
58
|
+
title: 'Explain one failing test',
|
|
59
|
+
description: 'Everything the agent knows about one failing test: root cause, confidence and source, full evidence, history on this commit versus others, the next action and why.',
|
|
60
|
+
inputSchema: { test: z.string().describe('Part of the test name or file, case-insensitive'), dataset, run, ai },
|
|
61
|
+
annotations: readOnly,
|
|
62
|
+
}, ({ test, dataset: d, run: r, ai: a }) => answer(() => explainFailure(test, d, r, a ?? true)));
|
|
63
|
+
server.registerTool('test_plan', {
|
|
64
|
+
title: 'Write a test plan for a feature',
|
|
65
|
+
description: 'A test plan grounded in a project on this machine: its existing tests (read in full where related), the code the feature lives in, an optional git change and live pages, and the failures Tewip diagnosed there before. Returns risks, prioritised scenarios, what is already covered and what to automate next. Saves the plan in the project\'s .tewip/plans (nothing else is written). Needs OPENAI_API_KEY; takes about a minute.',
|
|
66
|
+
inputSchema: {
|
|
67
|
+
feature: z.string().describe('What the feature does, in a sentence or two'),
|
|
68
|
+
project: z.string().describe('Absolute path of the project folder'),
|
|
69
|
+
diff: z.string().optional().describe('A git range whose change to plan for, e.g. "main...HEAD"'),
|
|
70
|
+
urls: z.array(z.string()).optional().describe('Pages of the running app to read, e.g. ["http://localhost:3000/checkout"]'),
|
|
71
|
+
},
|
|
72
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
73
|
+
}, ({ feature, project, diff, urls }) => answer(async () => {
|
|
74
|
+
if (!isAbsolute(project) || !existsSync(project))
|
|
75
|
+
throw new Error(`project must be an existing absolute path, got "${project}"`);
|
|
76
|
+
const { planProject } = await import('../engine/quality/plan-command.js');
|
|
77
|
+
const { markdown, out } = await planProject({ feature, root: project, diffRange: diff, urls: urls ?? [] });
|
|
78
|
+
return `${markdown}\n\n(Saved to ${out}. To turn it into verified tests, run \`tewip write\` in that folder.)`;
|
|
79
|
+
}));
|
|
80
|
+
await server.connect(new StdioServerTransport());
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// What the MCP server answers. Each function returns markdown built by the same engine and
|
|
2
|
+
// role views as the CLI and the GitHub Action (one engine, many outlets). Read-only.
|
|
3
|
+
import { loadCorrections } from '../engine/corrections.js';
|
|
4
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
5
|
+
import { join, relative } from 'node:path';
|
|
6
|
+
import { lastFailedAttempts, loadDataset } from '../engine/dataset.js';
|
|
7
|
+
import { HistoryIndex } from '../engine/history.js';
|
|
8
|
+
import { llmFromEnv } from '../engine/llm/client.js';
|
|
9
|
+
import { historyPattern } from '../engine/llm/context.js';
|
|
10
|
+
import { buildItems } from '../outlets/items.js';
|
|
11
|
+
import { clean, itemBlock, PLAIN_CATEGORY } from '../outlets/view.js';
|
|
12
|
+
import { architectureView } from '../outlets/views/architecture.js';
|
|
13
|
+
import { prCommentView } from '../outlets/views/developer.js';
|
|
14
|
+
import { digestView } from '../outlets/views/digest.js';
|
|
15
|
+
import { infraView } from '../outlets/views/infra.js';
|
|
16
|
+
import { qualityView } from '../outlets/views/quality.js';
|
|
17
|
+
import { releaseView } from '../outlets/views/release.js';
|
|
18
|
+
import { sdetBoardView } from '../outlets/views/sdet.js';
|
|
19
|
+
import { DATA_DIR, PROJECT_ROOT } from '../reporters/store.js';
|
|
20
|
+
/** Keeps answers small enough for an assistant's context window. */
|
|
21
|
+
const MAX_CHARS = 40_000;
|
|
22
|
+
export const cap = (s) => (s.length <= MAX_CHARS ? s : `${s.slice(0, MAX_CHARS)}\n\n…truncated (${s.length} characters in total).`);
|
|
23
|
+
const label = (dir) => relative(PROJECT_ROOT, dir).replaceAll('\\', '/') || '.';
|
|
24
|
+
/** Every directory holding collected failures: data/, data/lab, data/real/*. */
|
|
25
|
+
export function listDatasets() {
|
|
26
|
+
const dirs = [DATA_DIR, join(DATA_DIR, 'lab'), ...(existsSync(join(DATA_DIR, 'real')) ? readdirSync(join(DATA_DIR, 'real')).map((d) => join(DATA_DIR, 'real', d)) : [])];
|
|
27
|
+
const rows = dirs
|
|
28
|
+
.filter((d) => existsSync(join(d, 'triage.jsonl')))
|
|
29
|
+
.map((d) => {
|
|
30
|
+
const ds = loadDataset(d);
|
|
31
|
+
const latest = ds.summaries.map((s) => s.startedAt).sort().at(-1)?.slice(0, 10) ?? '-';
|
|
32
|
+
return `- \`${label(d)}\`: ${ds.records.length} failed attempts in ${ds.summaries.length} runs, latest ${latest}`;
|
|
33
|
+
});
|
|
34
|
+
return rows.length ? `# Datasets\n\n${rows.join('\n')}` : 'No collected failures yet. Run Playwright with the triage reporter, or harvest real CI data (see eval/README.md).';
|
|
35
|
+
}
|
|
36
|
+
function open(dir) {
|
|
37
|
+
const dataset = loadDataset(dir ? join(PROJECT_ROOT, dir) : undefined);
|
|
38
|
+
if (dataset.records.length === 0)
|
|
39
|
+
throw new Error(`No failure records in ${dataset.label}. Call list_datasets to see what is available.`);
|
|
40
|
+
return { dataset, index: new HistoryIndex(dataset.records, dataset.summaries) };
|
|
41
|
+
}
|
|
42
|
+
const failedRuns = (dataset) => dataset.summaries.filter((s) => dataset.records.some((r) => r.runId === s.runId)).sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
43
|
+
function resolveRun(dataset, run) {
|
|
44
|
+
const runs = failedRuns(dataset);
|
|
45
|
+
if (!run) {
|
|
46
|
+
if (!runs[0])
|
|
47
|
+
throw new Error('No run with failures in this dataset.');
|
|
48
|
+
return runs[0].runId;
|
|
49
|
+
}
|
|
50
|
+
const matches = runs.filter((s) => s.runId.includes(run));
|
|
51
|
+
if (matches.length !== 1)
|
|
52
|
+
throw new Error(matches.length === 0 ? `No failed run matches "${run}".` : `"${run}" matches ${matches.length} runs; be more specific.`);
|
|
53
|
+
return matches[0].runId;
|
|
54
|
+
}
|
|
55
|
+
const memo = new Map();
|
|
56
|
+
function itemsFor(dir, dataset, index, runId, ai) {
|
|
57
|
+
const key = `${dir ?? ''}|${runId ?? '*'}|${ai}`;
|
|
58
|
+
if (!memo.has(key)) {
|
|
59
|
+
const records = runId ? dataset.records.filter((r) => r.runId === runId) : dataset.records;
|
|
60
|
+
// Corrections people made are part of what the agent knows, so an assistant asking "why did
|
|
61
|
+
// CI fail?" is told the answer a person settled, not the one the agent had first.
|
|
62
|
+
const corrections = loadCorrections(dir ?? DATA_DIR);
|
|
63
|
+
memo.set(key, buildItems(records, index, { dataset: dataset.label, llm: ai ? llmFromEnv() : null, corrections }));
|
|
64
|
+
}
|
|
65
|
+
return memo.get(key);
|
|
66
|
+
}
|
|
67
|
+
export function listFailedRuns(dir, limit = 15) {
|
|
68
|
+
const { dataset } = open(dir);
|
|
69
|
+
const rows = failedRuns(dataset)
|
|
70
|
+
.slice(0, limit)
|
|
71
|
+
.map((s) => {
|
|
72
|
+
const failed = new Set(dataset.records.filter((r) => r.runId === s.runId).map((r) => r.testId)).size;
|
|
73
|
+
return `| ${s.runId} | ${s.startedAt.slice(0, 16).replace('T', ' ')} | ${s.branch || '-'} | ${failed} |`;
|
|
74
|
+
});
|
|
75
|
+
return `# Latest runs with failures in \`${dataset.label}\`\n\n| Run | Started (UTC) | Branch | Failing tests |\n|---|---|---|---:|\n${rows.join('\n')}`;
|
|
76
|
+
}
|
|
77
|
+
export async function runView(view, dir, run, ai = true) {
|
|
78
|
+
const { dataset, index } = open(dir);
|
|
79
|
+
const runId = resolveRun(dataset, run);
|
|
80
|
+
const items = await itemsFor(dir, dataset, index, runId, ai);
|
|
81
|
+
const render = { sdet: sdetBoardView, developer: prCommentView, environment: infraView, release: releaseView }[view];
|
|
82
|
+
return cap(render(items, runId, dataset.label).markdown);
|
|
83
|
+
}
|
|
84
|
+
export async function windowView(view, dir, ai = true) {
|
|
85
|
+
const { dataset, index } = open(dir);
|
|
86
|
+
const items = await itemsFor(dir, dataset, index, null, ai);
|
|
87
|
+
if (view === 'digest')
|
|
88
|
+
return cap(digestView(items, dataset.summaries, dataset.label).markdown);
|
|
89
|
+
return cap((view === 'quality' ? qualityView : architectureView)(items, dataset.label).markdown);
|
|
90
|
+
}
|
|
91
|
+
/** Everything the agent knows about one failing test: diagnosis, evidence, history, next action. */
|
|
92
|
+
export async function explainFailure(test, dir, run, ai = true) {
|
|
93
|
+
const { dataset, index } = open(dir);
|
|
94
|
+
const needle = test.toLowerCase();
|
|
95
|
+
const candidates = lastFailedAttempts(dataset.records).filter((r) => r.testId.toLowerCase().includes(needle) && (!run || r.runId.includes(run)));
|
|
96
|
+
if (candidates.length === 0)
|
|
97
|
+
return `No failed test matching "${test}" in \`${dataset.label}\`${run ? ` run ${run}` : ''}.`;
|
|
98
|
+
const latest = candidates.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
|
99
|
+
const distinctTests = [...new Set(latest.map((r) => r.testId))];
|
|
100
|
+
if (distinctTests.length > 1)
|
|
101
|
+
return `"${test}" matches ${distinctTests.length} tests; be more specific:\n\n${distinctTests.slice(0, 20).map((t) => `- ${clean(t)}`).join('\n')}`;
|
|
102
|
+
const record = latest[0];
|
|
103
|
+
const [item] = await itemsFor(dir, dataset, index, record.runId, ai).then((xs) => xs.filter((i) => i.testId === record.testId));
|
|
104
|
+
const failedIn = new Set(latest.map((r) => r.runId)).size;
|
|
105
|
+
return cap([
|
|
106
|
+
`# ${clean(item.title)}`,
|
|
107
|
+
'',
|
|
108
|
+
itemBlock(item),
|
|
109
|
+
'',
|
|
110
|
+
`**In plain words:** ${PLAIN_CATEGORY[item.diagnosis.category]}.`,
|
|
111
|
+
`**Why this action:** ${item.action.reason}.`,
|
|
112
|
+
`**Related to the change that triggered the run?** ${item.relation.verdict}: ${item.relation.why}.`,
|
|
113
|
+
`**History:** ${historyPattern(index.historyAsOf(record.testId, record.runId))}. Failed in ${failedIn} run(s) of this dataset.`,
|
|
114
|
+
item.debt.length ? `**Test-design debt near the failing line:** ${item.debt.map((d) => `${d.kind} (${clean(d.detail)})`).join('; ')}` : '',
|
|
115
|
+
'',
|
|
116
|
+
'## Full evidence',
|
|
117
|
+
...item.diagnosis.evidence.map((e) => `- [${e.source}] ${clean(e.message)}`),
|
|
118
|
+
'',
|
|
119
|
+
'## Error',
|
|
120
|
+
'```',
|
|
121
|
+
record.errorMessage.slice(0, 3000),
|
|
122
|
+
'```',
|
|
123
|
+
].join('\n'));
|
|
124
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Test-design debt visible in a failure: fixed waits and brittle selectors. Read from the
|
|
2
|
+
// failing locator and the code around the failing line only (not the whole spec).
|
|
3
|
+
const HARD_WAIT = /waitForTimeout\(\s*\d+|new Promise\([^)]*setTimeout|\bsleep\(\s*\d+/;
|
|
4
|
+
const BRITTLE = [
|
|
5
|
+
{ pattern: /nth-child|nth-of-type|\.nth\(|:nth-match|\.first\(\)|\.last\(\)/, why: 'position-based' },
|
|
6
|
+
{ pattern: /xpath=|^locator\(\s*['"]\/\//, why: 'XPath' },
|
|
7
|
+
{ pattern: /^locator\(\s*['"][^'"]*\s>\s/, why: 'deep CSS chain' },
|
|
8
|
+
{ pattern: /^locator\(\s*['"][^'"#]*\.[a-zA-Z_-][\w-]*/, why: 'CSS class' },
|
|
9
|
+
];
|
|
10
|
+
export function designDebt(locator, code) {
|
|
11
|
+
const debt = [];
|
|
12
|
+
const waitLine = code?.find((line) => HARD_WAIT.test(line.split('|').slice(1).join('|')));
|
|
13
|
+
if (waitLine)
|
|
14
|
+
debt.push({ kind: 'hard-wait', detail: waitLine.split('|').slice(1).join('|').trim() });
|
|
15
|
+
// Role / label / test-id locators are the robust kind; only raw CSS/XPath can be brittle.
|
|
16
|
+
if (locator && !/^getBy/.test(locator)) {
|
|
17
|
+
const hit = BRITTLE.find((b) => b.pattern.test(locator));
|
|
18
|
+
if (hit)
|
|
19
|
+
debt.push({ kind: 'brittle-selector', detail: `${hit.why}: ${locator}` });
|
|
20
|
+
}
|
|
21
|
+
return debt;
|
|
22
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// The unified triage item: one per failed test per run, built once by the engine and
|
|
2
|
+
// rendered by every role's view (ROADMAP principle 3: one engine, many outlets).
|
|
3
|
+
import { extractLocator } from '../engine/error-parse.js';
|
|
4
|
+
import { decide } from '../engine/decide.js';
|
|
5
|
+
import { lastFailedAttempts } from '../engine/dataset.js';
|
|
6
|
+
import { failingCodeOf } from '../engine/llm/context.js';
|
|
7
|
+
import { diagnosePipeline } from '../engine/pipeline.js';
|
|
8
|
+
import { truncate } from '../engine/signature.js';
|
|
9
|
+
import { designDebt } from './design-debt.js';
|
|
10
|
+
const DEFAULT_BRANCHES = new Set((process.env.TRIAGE_DEFAULT_BRANCH ?? 'main,master,trunk').split(','));
|
|
11
|
+
/** Unknown branches count as default: without branch data, failures are treated as before. */
|
|
12
|
+
export const isDefaultBranch = (branch) => !branch || DEFAULT_BRANCHES.has(branch);
|
|
13
|
+
/** Product area from a spec path: its first two folders, or the spec name when it has none. */
|
|
14
|
+
export function componentOf(file) {
|
|
15
|
+
const path = file.replaceAll('\\', '/').replace(/^(?:test\/e2e\/specs|tests?|e2e|specs?)\//, '');
|
|
16
|
+
const dirs = path.split('/').slice(0, -1);
|
|
17
|
+
return dirs.length > 0 ? dirs.slice(0, 2).join('/') : path.replace(/\.(spec|test)\.[jt]sx?$/, '');
|
|
18
|
+
}
|
|
19
|
+
function relationOf(d, h) {
|
|
20
|
+
if (d.category === 'ENV')
|
|
21
|
+
return { verdict: 'not-this-change', why: 'environment problem, not code (see the infrastructure view)' };
|
|
22
|
+
if (h.currentRunFailures < h.currentRunAttempts)
|
|
23
|
+
return { verdict: 'not-this-change', why: 'a retry of the same code passed in this run: the test is flaky' };
|
|
24
|
+
if (h.failingRunsOnOtherCommits >= 2)
|
|
25
|
+
return { verdict: 'not-this-change', why: `it also failed in ${h.failingRunsOnOtherCommits} runs on other commits: an existing problem` };
|
|
26
|
+
if (h.sameCommitAttempts > 0 && h.sameCommitFailures === h.sameCommitAttempts && h.passesOnOtherCommits > 0) {
|
|
27
|
+
return { verdict: 'likely-this-change', why: `every attempt on this commit failed (${h.sameCommitFailures}/${h.sameCommitAttempts}) while it passed ${h.passesOnOtherCommits} time(s) on other commits` };
|
|
28
|
+
}
|
|
29
|
+
return { verdict: 'unclear', why: 'not enough history on other commits to tell' };
|
|
30
|
+
}
|
|
31
|
+
export function sourceOf(d, dataset) {
|
|
32
|
+
const who = d.source === 'human' ? 'corrected by a person' : d.source === 'llm' ? `AI ${d.model ?? 'model'}` : `rules ${d.engineVersion}`;
|
|
33
|
+
return `${who} · data: ${dataset}`;
|
|
34
|
+
}
|
|
35
|
+
function suggestedLocator(d) {
|
|
36
|
+
const replacement = d.evidence.map((e) => e.data?.replacement).find((r) => typeof r === 'string');
|
|
37
|
+
if (replacement)
|
|
38
|
+
return replacement;
|
|
39
|
+
const candidate = d.evidence.map((e) => e.data?.candidate).find(Boolean);
|
|
40
|
+
if (!candidate?.role)
|
|
41
|
+
return undefined;
|
|
42
|
+
return candidate.name ? `getByRole('${candidate.role}', { name: '${candidate.name.replaceAll("'", "\\'")}' })` : `getByRole('${candidate.role}')`;
|
|
43
|
+
}
|
|
44
|
+
/** Diagnoses every failed test of every run in `records` and wraps each as a TriageItem. */
|
|
45
|
+
export async function buildItems(records, index, { dataset, llm, reuseStoredDiagnoses = false, corrections = [] }) {
|
|
46
|
+
const cases = lastFailedAttempts(records);
|
|
47
|
+
const stored = reuseStoredDiagnoses ? cases.map((c) => c.diagnosis) : cases.map(() => undefined);
|
|
48
|
+
const missing = cases.filter((_, i) => !stored[i]);
|
|
49
|
+
const fresh = await diagnosePipeline(missing, index, llm, { corrections });
|
|
50
|
+
const diagnoses = stored.map((d) => d ?? fresh.shift());
|
|
51
|
+
const failedAttempts = new Map();
|
|
52
|
+
for (const r of records)
|
|
53
|
+
failedAttempts.set(`${r.runId} ${r.testId}`, [...(failedAttempts.get(`${r.runId} ${r.testId}`) ?? []), r]);
|
|
54
|
+
return cases.map((record, i) => {
|
|
55
|
+
const d = diagnoses[i];
|
|
56
|
+
const history = index.historyAsOf(record.testId, record.runId);
|
|
57
|
+
const { branch } = index.runContext(record.runId);
|
|
58
|
+
const attempts = failedAttempts.get(`${record.runId} ${record.testId}`) ?? [record];
|
|
59
|
+
const locator = extractLocator(record.errorMessage) ?? undefined;
|
|
60
|
+
const debt = designDebt(locator ?? null, failingCodeOf(record));
|
|
61
|
+
const component = componentOf(record.file);
|
|
62
|
+
const hardWait = debt.find((x) => x.kind === 'hard-wait')?.detail;
|
|
63
|
+
return {
|
|
64
|
+
runId: record.runId,
|
|
65
|
+
testId: record.testId,
|
|
66
|
+
title: record.testId.split(' > ').at(-1) ?? record.testId,
|
|
67
|
+
file: record.file,
|
|
68
|
+
component,
|
|
69
|
+
commit: record.gitSha,
|
|
70
|
+
branch,
|
|
71
|
+
onDefaultBranch: isDefaultBranch(branch),
|
|
72
|
+
timestamp: record.timestamp,
|
|
73
|
+
failedAttempts: attempts.length,
|
|
74
|
+
wastedMs: attempts.reduce((sum, a) => sum + a.duration, 0),
|
|
75
|
+
errorHead: truncate(record.errorMessage.split('\n')[0] ?? '', 160),
|
|
76
|
+
diagnosis: d,
|
|
77
|
+
action: decide(d, {
|
|
78
|
+
locator,
|
|
79
|
+
suggestedLocator: suggestedLocator(d),
|
|
80
|
+
hardWait,
|
|
81
|
+
strictMatches: Number(record.errorMessage.match(/strict mode violation: .* resolved to (\d+) elements/)?.[1]) || undefined,
|
|
82
|
+
component,
|
|
83
|
+
commit: record.gitSha,
|
|
84
|
+
tracePath: record.attachments.find((a) => a.name === 'trace')?.path,
|
|
85
|
+
}),
|
|
86
|
+
source: sourceOf(d, dataset),
|
|
87
|
+
evidence: d.evidence.slice(0, 3).map((e) => truncate(e.message, 220)),
|
|
88
|
+
recoveredOnRetry: history.currentRunFailures < history.currentRunAttempts,
|
|
89
|
+
relation: relationOf(d, history),
|
|
90
|
+
debt,
|
|
91
|
+
locator,
|
|
92
|
+
};
|
|
93
|
+
});
|
|
94
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase-4 routing: the views each category must reach. Run-level views only; the window
|
|
3
|
+
* views aggregate. Low-confidence and UNKNOWN failures always land on the SDET board.
|
|
4
|
+
*/
|
|
5
|
+
export const ROUTES = {
|
|
6
|
+
REAL_BUG: ['developer', 'business', 'sdet'],
|
|
7
|
+
ENV: ['devops', 'developer', 'sdet'],
|
|
8
|
+
TIMING: ['developer', 'sdet'],
|
|
9
|
+
LOCATOR_DRIFT: ['developer', 'sdet'],
|
|
10
|
+
TEST_DATA: ['developer', 'sdet'],
|
|
11
|
+
UNKNOWN: ['developer', 'sdet'],
|
|
12
|
+
};
|
|
13
|
+
export const PLAIN_CATEGORY = {
|
|
14
|
+
REAL_BUG: 'product problem',
|
|
15
|
+
ENV: 'test environment problem',
|
|
16
|
+
TIMING: 'unreliable (flaky) test',
|
|
17
|
+
LOCATOR_DRIFT: 'test needs updating after a UI change',
|
|
18
|
+
TEST_DATA: 'test data problem',
|
|
19
|
+
UNKNOWN: 'not understood yet',
|
|
20
|
+
};
|
|
21
|
+
/** A zero-width space: invisible, but it stops GitHub from reading the text around it as syntax. */
|
|
22
|
+
const ZWSP = '';
|
|
23
|
+
/**
|
|
24
|
+
* Markdown-safe single line from a test run: no backticks, pipes or line breaks. Test names and
|
|
25
|
+
* error messages come from the pull request's own code, so on a public repository its author
|
|
26
|
+
* controls them: without this they could ping people (@name), embed tracking images or disguised
|
|
27
|
+
* links, or inject HTML into the agent's comment.
|
|
28
|
+
*/
|
|
29
|
+
export const clean = (s) => s
|
|
30
|
+
.replaceAll('`', "'")
|
|
31
|
+
.replaceAll('|', '/')
|
|
32
|
+
.replaceAll('<', '<')
|
|
33
|
+
.replace(/@(?=[\w-])/g, `@${ZWSP}`) // "@name" reads the same but notifies no one
|
|
34
|
+
.replace(/!?\[([^\]\n]*)\]\(/g, `[$1]${ZWSP}(`) // links and images show as plain text
|
|
35
|
+
.replace(/\s+/g, ' ')
|
|
36
|
+
.trim();
|
|
37
|
+
export const confidenceText = (d) => (d.category === 'UNKNOWN' ? 'confidence 0.00 (undecided)' : `confidence ${d.confidence.toFixed(2)}`);
|
|
38
|
+
export const diagnosisLine = (i) => `${i.diagnosis.category}, ${confidenceText(i.diagnosis)}, source: ${clean(i.source)}`;
|
|
39
|
+
export const actionLine = (i) => `${i.action.owner}${i.action.autonomous ? ', the agent may do this on its own' : ''}): ${clean(i.action.summary)}`;
|
|
40
|
+
/** The standard block for one failure. Views may add lines after it, never drop these. */
|
|
41
|
+
export function itemBlock(i, extra = [], number) {
|
|
42
|
+
return [
|
|
43
|
+
`- ${number ? `**#${number}** ` : ''}**${clean(i.title)}** (\`${clean(i.file)}\`, run ${clean(i.runId)})`,
|
|
44
|
+
` - Diagnosis: ${diagnosisLine(i)}`,
|
|
45
|
+
` - Next action (${actionLine(i)}`,
|
|
46
|
+
` - Evidence: ${i.evidence.length > 0 ? i.evidence.map(clean).join(' · ') : '(none)'}`,
|
|
47
|
+
...extra.map((line) => ` - ${line}`),
|
|
48
|
+
].join('\n');
|
|
49
|
+
}
|
|
50
|
+
/** Why an item is not properly shown in a view, or null when it is. */
|
|
51
|
+
export function missingFromView(view, i) {
|
|
52
|
+
if (!view.items.includes(i))
|
|
53
|
+
return `not listed in ${view.id}`;
|
|
54
|
+
if (!view.markdown.includes(diagnosisLine(i)))
|
|
55
|
+
return `${view.id} lacks its diagnosis, confidence or source`;
|
|
56
|
+
if (!view.markdown.includes(clean(i.action.summary)))
|
|
57
|
+
return `${view.id} lacks its next action`;
|
|
58
|
+
if (i.evidence.length === 0 || !view.markdown.includes(clean(i.evidence[0])))
|
|
59
|
+
return `${view.id} lacks its evidence`;
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
/** Header line that ties a view's numbers to the dataset they came from. */
|
|
63
|
+
export const sourceNote = (dataset) => `> Data: \`${dataset}\`. Every diagnosis shows its confidence and whether rules or the AI model produced it. Measured accuracy on real CI data: see eval/README.md.`;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { clean, itemBlock, sourceNote } from '../view.js';
|
|
2
|
+
import { groupEnv } from './infra.js';
|
|
3
|
+
/** A run where one commit broke at least this many tests, or this many areas, counts as a wide break. */
|
|
4
|
+
const WIDE_BREAK_TESTS = 5;
|
|
5
|
+
const WIDE_BREAK_AREAS = 3;
|
|
6
|
+
export function architectureView(items, dataset) {
|
|
7
|
+
const shown = [];
|
|
8
|
+
const example = (i) => {
|
|
9
|
+
shown.push(i);
|
|
10
|
+
return itemBlock(i, ['(example)']);
|
|
11
|
+
};
|
|
12
|
+
const areas = [...new Set(items.map((i) => i.component))]
|
|
13
|
+
.map((component) => {
|
|
14
|
+
const xs = items.filter((i) => i.component === component);
|
|
15
|
+
const count = (c) => xs.filter((i) => i.diagnosis.category === c).length;
|
|
16
|
+
const bugs = xs.filter((i) => i.diagnosis.category === 'REAL_BUG');
|
|
17
|
+
// Regressions a pull request introduced and CI caught before merge are the process working,
|
|
18
|
+
// not fragility of the main branch, so they are counted apart.
|
|
19
|
+
return {
|
|
20
|
+
component,
|
|
21
|
+
total: xs.length,
|
|
22
|
+
bug: bugs.filter((i) => i.onDefaultBranch).length,
|
|
23
|
+
caught: bugs.filter((i) => !i.onDefaultBranch).length,
|
|
24
|
+
env: count('ENV'),
|
|
25
|
+
timing: count('TIMING'),
|
|
26
|
+
};
|
|
27
|
+
})
|
|
28
|
+
.sort((a, b) => b.bug + b.env - (a.bug + a.env) || b.total - a.total)
|
|
29
|
+
.slice(0, 12);
|
|
30
|
+
const bugsByRun = new Map();
|
|
31
|
+
for (const i of items.filter((x) => x.diagnosis.category === 'REAL_BUG'))
|
|
32
|
+
bugsByRun.set(i.runId, [...(bugsByRun.get(i.runId) ?? []), i]);
|
|
33
|
+
const wideBreaks = [...bugsByRun.values()]
|
|
34
|
+
.map((xs) => ({ xs, areas: [...new Set(xs.map((i) => i.component))] }))
|
|
35
|
+
.filter((b) => b.xs.length >= WIDE_BREAK_TESTS || b.areas.length >= WIDE_BREAK_AREAS)
|
|
36
|
+
.sort((a, b) => b.xs.length - a.xs.length)
|
|
37
|
+
.slice(0, 5);
|
|
38
|
+
const deps = groupEnv(items).slice(0, 6);
|
|
39
|
+
const markdown = [
|
|
40
|
+
'# Architecture view: whole window',
|
|
41
|
+
'',
|
|
42
|
+
sourceNote(dataset),
|
|
43
|
+
'',
|
|
44
|
+
'## Fragility by area',
|
|
45
|
+
'',
|
|
46
|
+
'| Area | Failing cases | Product bugs (main branch) | Regressions caught in PRs | Environment | Flaky |',
|
|
47
|
+
'|---|---:|---:|---:|---:|---:|',
|
|
48
|
+
...areas.map((a) => `| ${clean(a.component)} | ${a.total} | ${a.bug} | ${a.caught} | ${a.env} | ${a.timing} |`),
|
|
49
|
+
'',
|
|
50
|
+
`Next action: review the top area's dependencies and shared components first; product bugs and environment failures there cost every team.`,
|
|
51
|
+
'',
|
|
52
|
+
'## Single changes that broke many suites at once',
|
|
53
|
+
'',
|
|
54
|
+
...(wideBreaks.length === 0
|
|
55
|
+
? ['None in this window.', '']
|
|
56
|
+
: wideBreaks.flatMap((b) => [
|
|
57
|
+
`### Commit \`${b.xs[0].commit.slice(0, 8)}\` (run ${clean(b.xs[0].runId)}${b.xs[0].onDefaultBranch ? '' : `, pull request branch ${clean(b.xs[0].branch)}, caught before merge`}): ${b.xs.length} test(s) across ${b.areas.length} area(s)`,
|
|
58
|
+
`Areas: ${b.areas.map(clean).join(', ')}.`,
|
|
59
|
+
'Next action: look at what this commit changed in shared code; one change breaking this many suites points to tight coupling.',
|
|
60
|
+
'',
|
|
61
|
+
example(b.xs[0]),
|
|
62
|
+
'',
|
|
63
|
+
])),
|
|
64
|
+
'## Service and environment dependencies that failed',
|
|
65
|
+
'',
|
|
66
|
+
...(deps.length === 0
|
|
67
|
+
? ['None in this window.', '']
|
|
68
|
+
: deps.flatMap((g) => [
|
|
69
|
+
`### ${clean(g.signature)}: ${g.items.length} failure(s) in ${new Set(g.items.map((i) => i.runId)).size} run(s), ${g.recovered} recovered on retry`,
|
|
70
|
+
'Next action: make this dependency more resilient (health check before the suite, retries on connection reset) or isolate the tests from it.',
|
|
71
|
+
'',
|
|
72
|
+
example(g.items[0]),
|
|
73
|
+
'',
|
|
74
|
+
])),
|
|
75
|
+
].join('\n');
|
|
76
|
+
return { id: 'architecture', role: 'architect', title: 'Architecture view', scope: 'window', items: [...new Set(shown)], markdown };
|
|
77
|
+
}
|