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.
Files changed (66) hide show
  1. package/LICENSE +202 -0
  2. package/QUICKSTART.md +178 -0
  3. package/README.md +308 -0
  4. package/TEAM-GUIDE.md +234 -0
  5. package/dist/cli/tewip.js +117 -0
  6. package/dist/engine/autofix/autofix.js +63 -0
  7. package/dist/engine/autofix/candidates.js +33 -0
  8. package/dist/engine/autofix/locate.js +78 -0
  9. package/dist/engine/corrections.js +66 -0
  10. package/dist/engine/dataset.js +41 -0
  11. package/dist/engine/decide.js +130 -0
  12. package/dist/engine/device-errors.js +45 -0
  13. package/dist/engine/drift-rules.js +98 -0
  14. package/dist/engine/driver-errors.js +103 -0
  15. package/dist/engine/engine.js +76 -0
  16. package/dist/engine/error-parse.js +150 -0
  17. package/dist/engine/history.js +105 -0
  18. package/dist/engine/import/blob.js +165 -0
  19. package/dist/engine/import/junit.js +235 -0
  20. package/dist/engine/import/xml.js +104 -0
  21. package/dist/engine/llm/anthropic.js +59 -0
  22. package/dist/engine/llm/client.js +139 -0
  23. package/dist/engine/llm/context.js +141 -0
  24. package/dist/engine/llm/llm-diagnose.js +48 -0
  25. package/dist/engine/llm/prompt.js +40 -0
  26. package/dist/engine/llm/providers.js +54 -0
  27. package/dist/engine/llm/usage.js +69 -0
  28. package/dist/engine/pipeline.js +33 -0
  29. package/dist/engine/quality/frameworks.js +181 -0
  30. package/dist/engine/quality/grounding.js +203 -0
  31. package/dist/engine/quality/model.js +17 -0
  32. package/dist/engine/quality/plan-command.js +34 -0
  33. package/dist/engine/quality/plan-render.js +38 -0
  34. package/dist/engine/quality/plan.js +119 -0
  35. package/dist/engine/quality/write-command.js +146 -0
  36. package/dist/engine/quality/write-verify.js +140 -0
  37. package/dist/engine/quality/write.js +144 -0
  38. package/dist/engine/rules.js +174 -0
  39. package/dist/engine/signature.js +23 -0
  40. package/dist/engine/similarity.js +76 -0
  41. package/dist/engine/snapshot.js +56 -0
  42. package/dist/engine/timing-rules.js +188 -0
  43. package/dist/engine/trace/dom.js +92 -0
  44. package/dist/engine/trace/trace-summary.js +87 -0
  45. package/dist/engine/trace/zip.js +53 -0
  46. package/dist/eval/harvest/gh.js +21 -0
  47. package/dist/eval/harvest/harvest.js +117 -0
  48. package/dist/mcp/github-report.js +50 -0
  49. package/dist/mcp/server.js +80 -0
  50. package/dist/mcp/tools.js +124 -0
  51. package/dist/outlets/design-debt.js +22 -0
  52. package/dist/outlets/items.js +94 -0
  53. package/dist/outlets/view.js +63 -0
  54. package/dist/outlets/views/architecture.js +77 -0
  55. package/dist/outlets/views/developer.js +59 -0
  56. package/dist/outlets/views/digest.js +66 -0
  57. package/dist/outlets/views/infra.js +49 -0
  58. package/dist/outlets/views/quality.js +108 -0
  59. package/dist/outlets/views/release.js +33 -0
  60. package/dist/outlets/views/sdet.js +29 -0
  61. package/dist/reporters/run-context.js +20 -0
  62. package/dist/reporters/store.js +46 -0
  63. package/dist/reporters/triage-reporter.js +101 -0
  64. package/dist/reporters/types.js +4 -0
  65. package/dist/scripts/explain.js +58 -0
  66. package/package.json +81 -0
@@ -0,0 +1,181 @@
1
+ // Which test frameworks a project uses, where their tests live, and how to run one test file.
2
+ // Tewip writes tests in the project's own framework and style, so it reads this from the project
3
+ // (config files, dependencies, existing tests) instead of assuming Playwright. It looks in the root
4
+ // and two levels below, because many repositories keep browser tests in their own folder.
5
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
6
+ import { basename, dirname, join, relative } from 'node:path';
7
+ /**
8
+ * A Chrome for Karma when CHROME_BIN is not set: the Chromium Playwright installed, if any. Karma
9
+ * cannot start without one, and many machines have Playwright's browsers but no Chrome.
10
+ */
11
+ function karmaChrome() {
12
+ if (process.env.CHROME_BIN)
13
+ return {};
14
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? '';
15
+ const root = process.env.PLAYWRIGHT_BROWSERS_PATH ?? (process.platform === 'win32' ? join(process.env.LOCALAPPDATA ?? '', 'ms-playwright') : process.platform === 'darwin' ? join(home, 'Library', 'Caches', 'ms-playwright') : join(home, '.cache', 'ms-playwright'));
16
+ if (!existsSync(root))
17
+ return {};
18
+ const builds = readdirSync(root).filter((d) => /^chromium-\d+$/.test(d)).sort((a, b) => Number(b.split('-')[1]) - Number(a.split('-')[1]));
19
+ const exe = ['chrome-win64/chrome.exe', 'chrome-win/chrome.exe', 'chrome-linux64/chrome', 'chrome-linux/chrome', 'chrome-mac/Chromium.app/Contents/MacOS/Chromium', 'chrome-mac-arm64/Chromium.app/Contents/MacOS/Chromium'];
20
+ for (const build of builds)
21
+ for (const e of exe)
22
+ if (existsSync(join(root, build, e)))
23
+ return { CHROME_BIN: join(root, build, e) };
24
+ return {};
25
+ }
26
+ const SKIP = new Set(['node_modules', '.git', '.tewip', 'dist', 'build', 'target', 'venv', '.venv', '__pycache__', 'bin', 'obj', 'vendor']);
27
+ const q = (s) => `"${s}"`;
28
+ const read = (p) => (existsSync(p) ? readFileSync(p, 'utf8') : '');
29
+ function dirs(root, depth = 2) {
30
+ const out = [root];
31
+ if (depth === 0)
32
+ return out;
33
+ for (const name of readdirSync(root)) {
34
+ if (SKIP.has(name) || name.startsWith('.'))
35
+ continue;
36
+ const p = join(root, name);
37
+ if (statSync(p).isDirectory())
38
+ out.push(...dirs(p, depth - 1));
39
+ }
40
+ return out;
41
+ }
42
+ /** Test files under `dir` whose names match, newest-looking first (shortest path wins ties). */
43
+ function testFiles(dir, match, depth = 6) {
44
+ if (!existsSync(dir) || depth < 0)
45
+ return [];
46
+ const out = [];
47
+ for (const name of readdirSync(dir)) {
48
+ if (SKIP.has(name))
49
+ continue;
50
+ const p = join(dir, name);
51
+ if (statSync(p).isDirectory())
52
+ out.push(...testFiles(p, match, depth - 1));
53
+ else if (match.test(name) && !/tewip/i.test(name))
54
+ out.push(p);
55
+ }
56
+ return out.sort((a, b) => a.length - b.length);
57
+ }
58
+ const kebab = (id) => id.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
59
+ const pascal = (id) => kebab(id).split('-').filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1)).join('');
60
+ /** "tewip-<id><suffix>", with the suffix the project's own tests use. */
61
+ function sameSuffix(example, fallback, known) {
62
+ const suffix = example?.match(known)?.[0] ?? fallback;
63
+ return (id) => `tewip-${kebab(id)}${suffix}`;
64
+ }
65
+ /** Every JavaScript test framework set up in this folder: a project often has e2e and unit tests. */
66
+ function jsFrameworks(dir, deps) {
67
+ const out = [];
68
+ const config = (names) => names.map((n) => join(dir, n)).find(existsSync);
69
+ const pw = config(['playwright.config.ts', 'playwright.config.js', 'playwright.config.mjs']);
70
+ if (pw) {
71
+ const testDir = join(dir, read(pw).match(/testDir:\s*['"`]([^'"`]+)/)?.[1] ?? '.');
72
+ const example = testFiles(testDir, /\.(spec|test)\.[mc]?[jt]s$/)[0] ?? null;
73
+ out.push({ id: 'playwright', label: 'Playwright Test (TypeScript/JavaScript)', browser: true, cwd: dir, testDir: example ? dirname(example) : testDir, example, fileFor: sameSuffix(example, '.spec.ts', /\.(spec|test)\.[mc]?[jt]s$/), command: (f) => `npx playwright test ${q(relative(dir, f).replaceAll('\\', '/'))} --retries=0 --workers=1 --reporter=line` });
74
+ }
75
+ const cy = config(['cypress.config.ts', 'cypress.config.js', 'cypress.config.mjs']);
76
+ if (cy) {
77
+ const example = testFiles(join(dir, 'cypress'), /\.cy\.[jt]sx?$/)[0] ?? null;
78
+ out.push({ id: 'cypress', label: 'Cypress (JavaScript/TypeScript)', browser: true, cwd: dir, testDir: example ? dirname(example) : join(dir, 'cypress', 'e2e'), example, fileFor: sameSuffix(example, '.cy.ts', /\.cy\.[jt]sx?$/), command: (f) => `npx cypress run --spec ${q(relative(dir, f))}` });
79
+ }
80
+ const wdio = config(['wdio.conf.ts', 'wdio.conf.js']);
81
+ if (wdio) {
82
+ const example = testFiles(dir, /\.(e2e|spec|test)\.[jt]s$/)[0] ?? null;
83
+ out.push({ id: 'webdriverio', label: 'WebdriverIO (JavaScript/TypeScript)', browser: true, cwd: dir, testDir: example ? dirname(example) : join(dir, 'test', 'specs'), example, fileFor: sameSuffix(example, '.e2e.ts', /\.(e2e|spec|test)\.[jt]s$/), command: (f) => `npx wdio run ${q(basename(wdio))} --spec ${q(relative(dir, f))}` });
84
+ }
85
+ // Angular runs its unit tests through the CLI (Karma with Jasmine, or the Vitest-based unit-test
86
+ // builder), with the project's TestBed setup, so a spec must run through `ng test` as well.
87
+ const angular = read(join(dir, 'angular.json'));
88
+ const builder = angular.match(/"test"\s*:\s*\{\s*"builder"\s*:\s*"([^"]+)"/)?.[1] ?? '';
89
+ const hasAngular = Boolean(angular && /karma|unit-test/.test(builder));
90
+ if (hasAngular) {
91
+ const example = testFiles(join(dir, 'src'), /\.spec\.ts$/)[0] ?? null;
92
+ const karma = builder.includes('karma');
93
+ out.push({
94
+ id: 'angular',
95
+ label: `Angular (ng test with ${karma ? 'Karma and Jasmine' : 'Vitest'}, TestBed; TypeScript)`,
96
+ browser: false,
97
+ cwd: dir,
98
+ testDir: example ? dirname(example) : join(dir, 'src', 'app'),
99
+ example,
100
+ fileFor: (id) => `tewip-${kebab(id)}.spec.ts`,
101
+ command: (f) => `npx ng test --watch=false --include=${q(relative(dir, f).replaceAll('\\', '/'))}${karma ? ' --browsers=ChromeHeadless' : ''}`,
102
+ env: karma ? karmaChrome() : undefined,
103
+ });
104
+ }
105
+ for (const id of ['vitest', 'jest']) {
106
+ if (!new RegExp(`"${id}"\\s*:`).test(deps))
107
+ continue;
108
+ const example = testFiles(dir, /\.(spec|test)\.[mc]?[jt]sx?$/)[0] ?? null;
109
+ const browser = /"selenium-webdriver"\s*:|"puppeteer"\s*:/.test(deps);
110
+ // Name the UI library and Testing Library, so component tests are written the way this project writes them.
111
+ const ui = [['react', 'React'], ['vue', 'Vue'], ['svelte', 'Svelte'], ['solid-js', 'Solid'], ['@angular/core', 'Angular']].filter(([dep]) => new RegExp(`"${dep}"\\s*:`).test(deps)).map(([, name]) => name);
112
+ const testingLibrary = /"@testing-library\/[\w-]+"\s*:/.test(deps) ? ' and Testing Library' : '';
113
+ const label = `${id === 'jest' ? 'Jest' : 'Vitest'}${browser ? ' with Selenium/Puppeteer' : ''}${ui.length ? ` for ${ui.join('/')}${testingLibrary}` : ''} (JavaScript/TypeScript)`;
114
+ const run = id === 'jest' ? 'npx jest' : 'npx vitest run';
115
+ out.push({ id, label, browser, cwd: dir, testDir: example ? dirname(example) : join(dir, 'test'), example, fileFor: sameSuffix(example, '.test.ts', /\.(spec|test)\.[mc]?[jt]sx?$/), command: (f) => `${run} ${q(relative(dir, f).replaceAll('\\', '/'))}` });
116
+ break;
117
+ }
118
+ return out;
119
+ }
120
+ /** The project's own Python (its virtual environment) when it has one, else the one on the PATH. */
121
+ function pythonOf(dir) {
122
+ for (const env of ['.venv', 'venv', 'env']) {
123
+ for (const bin of process.platform === 'win32' ? ['Scripts/python.exe'] : ['bin/python']) {
124
+ if (existsSync(join(dir, env, bin)))
125
+ return join(dir, env, bin);
126
+ }
127
+ }
128
+ return 'python';
129
+ }
130
+ function otherFramework(dir) {
131
+ const py = ['pytest.ini', 'conftest.py', 'pyproject.toml', 'requirements.txt', 'setup.cfg', 'tox.ini'].map((n) => read(join(dir, n))).join('\n');
132
+ const pyExample = testFiles(dir, /^test_.*\.py$|_test\.py$/)[0] ?? null;
133
+ if (pyExample && (/pytest/i.test(py) || existsSync(join(dir, 'conftest.py')))) {
134
+ const lib = /selenium/i.test(py) ? ' with Selenium' : /playwright/i.test(py) ? ' with Playwright' : '';
135
+ return { id: 'pytest', label: `pytest${lib} (Python)`, browser: lib !== '', cwd: dir, testDir: dirname(pyExample), example: pyExample, fileFor: (id) => `test_tewip_${kebab(id).replaceAll('-', '_')}.py`, command: (f) => `${q(pythonOf(dir))} -m pytest ${q(relative(dir, f))} -q -p no:cacheprovider` };
136
+ }
137
+ const javaExample = testFiles(join(dir, 'src', 'test'), /Tests?\.(java|kt)$/)[0] ?? null;
138
+ const pom = read(join(dir, 'pom.xml'));
139
+ const gradle = read(join(dir, 'build.gradle')) + read(join(dir, 'build.gradle.kts'));
140
+ if (javaExample && (pom || gradle)) {
141
+ const build = pom || gradle;
142
+ const lib = /selenium/i.test(build) ? ' with Selenium' : /spring-boot-starter-test/.test(build) ? ' on Spring Boot' : '';
143
+ const cls = (f) => basename(f).replace(/\.(java|kt)$/, '');
144
+ const lang = javaExample.endsWith('.kt') ? 'Kotlin' : 'Java';
145
+ const wrapper = existsSync(join(dir, process.platform === 'win32' ? 'gradlew.bat' : 'gradlew')) ? (process.platform === 'win32' ? 'gradlew.bat' : './gradlew') : 'gradle';
146
+ return pom
147
+ ? { id: 'maven', label: `JUnit via Maven${lib} (${lang})`, browser: lib === ' with Selenium', cwd: dir, testDir: dirname(javaExample), example: javaExample, fileFor: (id) => `Tewip${pascal(id)}Test.${javaExample.endsWith('.kt') ? 'kt' : 'java'}`, command: (f) => `mvn -q test -Dtest=${cls(f)} -Dsurefire.failIfNoSpecifiedTests=false` }
148
+ : { id: 'gradle', label: `JUnit via Gradle${lib} (${lang})`, browser: lib === ' with Selenium', cwd: dir, testDir: dirname(javaExample), example: javaExample, fileFor: (id) => `Tewip${pascal(id)}Test.${javaExample.endsWith('.kt') ? 'kt' : 'java'}`, command: (f) => `${wrapper} test --tests ${cls(f)}` };
149
+ }
150
+ const csproj = readdirSync(dir).find((n) => n.endsWith('.csproj') && /Microsoft\.NET\.Test\.Sdk/.test(read(join(dir, n))));
151
+ if (csproj) {
152
+ const example = testFiles(dir, /Tests?\.cs$/)[0] ?? null;
153
+ const lib = /Selenium/.test(read(join(dir, csproj))) ? ' with Selenium' : /Playwright/.test(read(join(dir, csproj))) ? ' with Playwright' : '';
154
+ return { id: 'dotnet', label: `.NET test${lib} (C#)`, browser: lib !== '', cwd: dir, testDir: example ? dirname(example) : dir, example, fileFor: (id) => `Tewip${pascal(id)}Tests.cs`, command: (f) => `dotnet test --filter FullyQualifiedName~${basename(f, '.cs')}` };
155
+ }
156
+ if (existsSync(join(dir, 'go.mod'))) {
157
+ const example = testFiles(dir, /_test\.go$/)[0] ?? null;
158
+ if (example)
159
+ return { id: 'go', label: 'Go testing', browser: false, cwd: dir, testDir: dirname(example), example, fileFor: (id) => `tewip_${kebab(id).replaceAll('-', '_')}_test.go`, command: (f) => `go test ./${relative(dir, dirname(f)).replaceAll('\\', '/') || '.'}` };
160
+ }
161
+ return null;
162
+ }
163
+ /** Every test framework found in the project, browser frameworks first. */
164
+ export function detectFrameworks(root) {
165
+ const found = [];
166
+ for (const dir of dirs(root)) {
167
+ const deps = read(join(dir, 'package.json'));
168
+ for (const f of [...jsFrameworks(dir, deps), otherFramework(dir)])
169
+ if (f && !found.some((x) => x.id === f.id && x.cwd === f.cwd))
170
+ found.push(f);
171
+ }
172
+ return found.sort((a, b) => Number(b.browser) - Number(a.browser));
173
+ }
174
+ /** The framework to write in: the one asked for, else the first of the kind the scenarios need, else the first. */
175
+ export function chooseFramework(found, wanted, browser = true) {
176
+ if (wanted)
177
+ return found.find((f) => f.id === wanted) ?? null;
178
+ // Scenarios on pages of the running app are browser tests; without pages they are unit or
179
+ // component tests, which belong in the project's unit framework even when it also has e2e tests.
180
+ return found.find((f) => f.browser === browser) ?? found[0] ?? null;
181
+ }
@@ -0,0 +1,203 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ // Facts Tewip reads before it plans or writes tests, so the model works from the project as it is
10
+ // instead of inventing it: the tests that already exist, what a change touched, and what the
11
+ // running app's pages actually contain.
12
+ import { execFileSync } from 'node:child_process';
13
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
14
+ import { createRequire } from 'node:module';
15
+ import { join, relative } from 'node:path';
16
+ import { pathToFileURL } from 'node:url';
17
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.tewip', '.venv', 'venv', '__pycache__', 'target', 'vendor', 'coverage', 'test-results', 'playwright-report', 'blob-report']);
18
+ const CODE_FILE = /\.(?:[cm]?[jt]sx?|py|java|kt|cs|go|rb|php|rs)$/;
19
+ const TEST_FILE = /(\.(spec|test)\.[cm]?[jt]sx?$)|(^test_.*\.py$)|(_test\.(py|go)$)|(Tests?\.(java|kt|cs)$)|(_spec\.rb$)|(Test\.php$)/;
20
+ /** Test names found in one file, for the languages Tewip reads reports from. */
21
+ function testNames(source) {
22
+ const patterns = [
23
+ /\b(?:test|it)(?:\.only|\.skip)?\(\s*(['"`])((?:(?!\1).)+)\1/g, // Playwright, Jest, Vitest, Mocha, node:test
24
+ /^\s*def (test_\w+)/gm, // pytest, unittest
25
+ /^func (Test\w+)\(/gm, // Go
26
+ /@Test[\s\S]{0,120}?\bvoid (\w+)\s*\(/g, // JUnit, TestNG
27
+ /\[(?:Fact|Test|TestMethod)\][\s\S]{0,120}?\bvoid (\w+)\s*\(/g, // xUnit, NUnit, MSTest
28
+ /^\s*it\s+['"]([^'"]+)['"]/gm, // RSpec
29
+ /function (test\w+)\s*\(/g, // PHPUnit
30
+ ];
31
+ const out = [];
32
+ for (const p of patterns)
33
+ for (const m of source.matchAll(p))
34
+ out.push((m[2] ?? m[1]).trim());
35
+ return out;
36
+ }
37
+ /** The tests already in the project, found by file name and read for their names. */
38
+ export function existingTests(root, cap = 300) {
39
+ const tests = [];
40
+ let files = 0;
41
+ const walk = (dir, depth) => {
42
+ if (depth > 8)
43
+ return;
44
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
45
+ if (entry.isDirectory()) {
46
+ if (!SKIP_DIRS.has(entry.name) && !entry.name.startsWith('.'))
47
+ walk(join(dir, entry.name), depth + 1);
48
+ }
49
+ else if (TEST_FILE.test(entry.name)) {
50
+ const path = join(dir, entry.name);
51
+ if (statSync(path).size > 1_000_000)
52
+ continue;
53
+ files++;
54
+ const rel = relative(root, path).replaceAll('\\', '/');
55
+ for (const name of testNames(readFileSync(path, 'utf8')))
56
+ if (tests.length < cap)
57
+ tests.push(`${rel}: ${name}`);
58
+ }
59
+ }
60
+ };
61
+ walk(root, 0);
62
+ return { files, tests };
63
+ }
64
+ const STOPWORDS = new Set(['with', 'that', 'this', 'from', 'into', 'when', 'then', 'them', 'they', 'their', 'each', 'what', 'which', 'reads', 'back', 'sees', 'must', 'should', 'have', 'does', 'test', 'tests', 'user', 'page', 'able', 'also', 'only', 'other', 'more', 'than', 'will']);
65
+ /**
66
+ * The full text of the tests most related to a feature. Test names alone hide what table-driven
67
+ * tests (test.each, parametrize, @ParameterizedTest, a list of cases in a loop) already check, so
68
+ * the planner also reads the files that mention the feature's words most, rare words counting
69
+ * most. With kind 'code', the same for the source files the feature lives in. Tests Tewip wrote
70
+ * itself are left out: they are not the project's own conventions.
71
+ */
72
+ export function relatedTestSources(root, feature, maxFiles = 4, maxChars = 8_000, kind = 'tests') {
73
+ const wanted = (name) => (kind === 'tests' ? TEST_FILE.test(name) : CODE_FILE.test(name) && !TEST_FILE.test(name) && !/\.d\.ts$|\.min\.js$|\.config\./.test(name));
74
+ const words = [...new Set(feature.match(/[A-Za-z_][A-Za-z0-9_]{3,}/g) ?? [])].filter((t) => !STOPWORDS.has(t.toLowerCase()));
75
+ // Names written like code (with_query, withQuery) almost surely point at the right file.
76
+ const codeLike = words.map((t) => /[a-z][A-Z]|_/.test(t));
77
+ const terms = words.map((t) => t.toLowerCase());
78
+ if (terms.length === 0)
79
+ return [];
80
+ const files = [];
81
+ const walk = (dir, depth) => {
82
+ if (depth > 8)
83
+ return;
84
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
85
+ const path = join(dir, entry.name);
86
+ if (entry.isDirectory()) {
87
+ if (!SKIP_DIRS.has(entry.name) && !entry.name.startsWith('.'))
88
+ walk(path, depth + 1);
89
+ }
90
+ else if (wanted(entry.name) && !/tewip/i.test(entry.name) && statSync(path).size <= 1_000_000) {
91
+ const content = readFileSync(path, 'utf8');
92
+ const lower = content.toLowerCase();
93
+ files.push({ file: relative(root, path).replaceAll('\\', '/'), content, hits: terms.map((t) => Math.min(20, lower.split(t).length - 1)) });
94
+ }
95
+ }
96
+ };
97
+ walk(root, 0);
98
+ // A word found in few files says more about which file is meant than one found everywhere
99
+ // (tf-idf): "naturalsize" picks the file-size tests, "number" or "format" pick nothing in particular.
100
+ const weight = terms.map((_, i) => Math.log(1 + files.length / Math.max(1, files.filter((f) => f.hits[i] > 0).length)) * (codeLike[i] ? 3 : 1));
101
+ // Pick greedily by what each file adds: once a chosen file covers a word, that word counts a
102
+ // quarter as much, so a feature naming two functions gets the tests of both.
103
+ const left = [...weight];
104
+ const chosen = [];
105
+ const gain = (f) => f.hits.reduce((sum, h, i) => sum + Math.log(1 + h) * left[i], 0);
106
+ while (chosen.length < maxFiles) {
107
+ const best = files.filter((f) => !chosen.includes(f)).sort((x, y) => gain(y) - gain(x))[0];
108
+ if (!best || gain(best) <= 0)
109
+ break;
110
+ chosen.push(best);
111
+ best.hits.forEach((h, i) => (left[i] = h > 0 ? left[i] / 4 : left[i]));
112
+ }
113
+ return chosen.map(({ file, content }) => ({ file, content: content.length > maxChars ? `${content.slice(0, maxChars)}\n... (cut)` : content }));
114
+ }
115
+ /** What a git range changed (e.g. "main...HEAD"); null outside a git checkout. */
116
+ export function changeSummary(root, range, maxPatch = 12_000) {
117
+ try {
118
+ const git = (args) => execFileSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
119
+ const files = git(['diff', '--name-only', range]).split('\n').filter(Boolean);
120
+ const patch = git(['diff', '--unified=2', range]);
121
+ return { range, files, patch: patch.length > maxPatch ? `${patch.slice(0, maxPatch)}\n... (diff cut at ${maxPatch} characters)` : patch };
122
+ }
123
+ catch {
124
+ return null;
125
+ }
126
+ }
127
+ /**
128
+ * Playwright as the project installed it, found from the project's own folders (the root, or a
129
+ * browser-test folder such as e2e/ one or two levels down), then Tewip's own copy. An installed
130
+ * Tewip cannot see the project's node_modules through a plain import, so it resolves them here.
131
+ */
132
+ async function loadChromium(root) {
133
+ const dirs = [root];
134
+ for (const a of readdirSync(root, { withFileTypes: true })) {
135
+ if (!a.isDirectory() || SKIP_DIRS.has(a.name) || a.name.startsWith('.'))
136
+ continue;
137
+ dirs.push(join(root, a.name));
138
+ for (const b of readdirSync(join(root, a.name), { withFileTypes: true })) {
139
+ if (b.isDirectory() && !SKIP_DIRS.has(b.name) && !b.name.startsWith('.'))
140
+ dirs.push(join(root, a.name, b.name));
141
+ }
142
+ }
143
+ for (const dir of dirs) {
144
+ for (const name of ['@playwright/test', 'playwright']) {
145
+ if (!existsSync(join(dir, 'node_modules', ...name.split('/'))))
146
+ continue;
147
+ try {
148
+ const entry = createRequire(join(dir, 'package.json')).resolve(name);
149
+ const m = (await import(__rewriteRelativeImportExtension(pathToFileURL(entry).href)));
150
+ if (m.chromium ?? m.default?.chromium)
151
+ return m.chromium ?? m.default.chromium;
152
+ }
153
+ catch {
154
+ // an unusable install here; try the next folder
155
+ }
156
+ }
157
+ }
158
+ for (const name of ['@playwright/test', 'playwright']) {
159
+ try {
160
+ return (await import(__rewriteRelativeImportExtension(name))).chromium;
161
+ }
162
+ catch {
163
+ // not bundled with Tewip either
164
+ }
165
+ }
166
+ return null;
167
+ }
168
+ /**
169
+ * What a page of the running app contains, read with Playwright. Returns no pages and a reason when
170
+ * Playwright or its browser is not available, rather than failing the whole command.
171
+ */
172
+ export async function readPages(urls, root = process.cwd(), maxAria = 8_000) {
173
+ if (urls.length === 0)
174
+ return { pages: [], note: null };
175
+ const chromium = await loadChromium(root);
176
+ if (!chromium)
177
+ return { pages: [], note: 'Playwright is not installed in this project, so the pages were not read (npm install -D @playwright/test && npx playwright install chromium).' };
178
+ let browser;
179
+ try {
180
+ browser = await chromium.launch();
181
+ }
182
+ catch (error) {
183
+ return { pages: [], note: `Playwright could not start a browser, so the pages were not read (npx playwright install chromium): ${String(error instanceof Error ? error.message : error).split('\n')[0]}` };
184
+ }
185
+ try {
186
+ const page = await browser.newPage();
187
+ const pages = [];
188
+ for (const url of urls) {
189
+ await page.goto(url, { waitUntil: 'networkidle', timeout: 30_000 });
190
+ const aria = await page.locator('body').ariaSnapshot();
191
+ pages.push({ url, title: await page.title(), aria: aria.length > maxAria ? `${aria.slice(0, maxAria)}\n... (cut)` : aria });
192
+ }
193
+ return { pages, note: null };
194
+ }
195
+ catch (error) {
196
+ return { pages: [], note: `The pages could not be read: ${String(error instanceof Error ? error.message : error).split('\n')[0]}` };
197
+ }
198
+ finally {
199
+ await browser.close();
200
+ }
201
+ }
202
+ /** The source files (not tests) most related to a feature: what it does and what its comments promise. */
203
+ export const relatedCodeSources = (root, feature) => relatedTestSources(root, feature, 2, 6_000, 'code');
@@ -0,0 +1,17 @@
1
+ // The model Tewip writes with. Triage uses a small, fast model because the deterministic rules do
2
+ // most of that work; planning and writing tests are open-ended, so they use a stronger one.
3
+ // Settings: TEWIP_WRITE_MODEL (default below), TEWIP_WRITE_REASONING (default medium).
4
+ import { join } from 'node:path';
5
+ import { cachedClient, loadDotEnv } from '../llm/client.js';
6
+ import { clientFor, modelChoiceFromEnv } from '../llm/providers.js';
7
+ export const DEFAULT_WRITE_MODEL = 'gpt-5.5';
8
+ /** The writing client, or null when no model is configured (writing needs one; triage does not). */
9
+ export function writerFromEnv(projectRoot) {
10
+ loadDotEnv();
11
+ const choice = modelChoiceFromEnv('write');
12
+ if (!choice)
13
+ return null;
14
+ const client = clientFor(choice);
15
+ // Same request, same answer, no second charge: kept with the project like the rest of .tewip.
16
+ return cachedClient(client, join(projectRoot, '.tewip', 'writer-cache.jsonl'));
17
+ }
@@ -0,0 +1,34 @@
1
+ // `tewip plan`: gather the facts, ask the writing model for a plan, render it, and keep it in
2
+ // .tewip/plans (Markdown to review, JSON for `tewip write` to build tests from). The same function
3
+ // serves the command line and the MCP server, so an editor's assistant gets the same plan.
4
+ import { mkdirSync, writeFileSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { noModelHelp } from '../llm/providers.js';
7
+ import { usageLine } from '../llm/usage.js';
8
+ import { writerFromEnv } from './model.js';
9
+ import { gatherPlanFacts, writePlan } from './plan.js';
10
+ import { renderPlan } from './plan-render.js';
11
+ const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 60) || 'plan';
12
+ /** Writes the plan into the project's .tewip/plans and returns it as Markdown, with where it went. */
13
+ export async function planProject(cmd, progress = () => { }) {
14
+ const model = writerFromEnv(cmd.root);
15
+ if (!model)
16
+ throw new Error(`writing a plan needs a model.
17
+ ${noModelHelp()}`);
18
+ progress('Tewip is reading the project...');
19
+ const facts = await gatherPlanFacts(cmd);
20
+ progress(` ${facts.tests.tests.length} existing test(s), ${facts.pages.length} page(s) read${facts.change ? `, ${facts.change.files.length} changed file(s)` : ''}${facts.history.length ? `, ${facts.history.length} past failure(s)` : ''}. Writing the plan...`);
21
+ const plan = await writePlan(facts, model);
22
+ const markdown = renderPlan(plan, facts, model.model);
23
+ const out = cmd.out ?? join(cmd.root, '.tewip', 'plans', `${slug(plan.title)}.md`);
24
+ mkdirSync(join(out, '..'), { recursive: true });
25
+ writeFileSync(out, markdown, 'utf8');
26
+ // The same plan as data, for `tewip write`: its scenarios, and the pages it read.
27
+ writeFileSync(out.replace(/\.md$/, '') + '.json', JSON.stringify({ plan, feature: facts.feature, urls: facts.pages.map((p) => p.url), model: model.model }, null, 2), 'utf8');
28
+ return { markdown, out };
29
+ }
30
+ export async function runPlan(cmd) {
31
+ const { markdown, out } = await planProject(cmd, (line) => console.error(line));
32
+ console.log(markdown);
33
+ console.error(`\nSaved to ${out}\n${usageLine()}`);
34
+ }
@@ -0,0 +1,38 @@
1
+ const cell = (s) => s.replaceAll('|', '/').replace(/\s+/g, ' ').trim();
2
+ export function renderPlan(plan, facts, model) {
3
+ const byPriority = (p) => plan.scenarios.filter((s) => s.priority === p);
4
+ const lines = [
5
+ `# Test plan: ${plan.title}`,
6
+ '',
7
+ `> Draft by Tewip (${model}). Review before use: every scenario says what it is grounded on.`,
8
+ '',
9
+ plan.summary,
10
+ '',
11
+ '## Scope',
12
+ ...plan.scope.map((s) => `- ${s}`),
13
+ ...(plan.out_of_scope.length ? ['', '**Out of scope:**', ...plan.out_of_scope.map((s) => `- ${s}`)] : []),
14
+ '',
15
+ '## Risks',
16
+ '',
17
+ '| Severity | Risk | Why |',
18
+ '|---|---|---|',
19
+ ...plan.risks.map((r) => `| ${r.severity} | ${cell(r.risk)} | ${cell(r.why)} |`),
20
+ '',
21
+ '## Scenarios',
22
+ ];
23
+ for (const p of ['P0', 'P1', 'P2']) {
24
+ const xs = byPriority(p);
25
+ if (xs.length === 0)
26
+ continue;
27
+ lines.push('', `### ${p} (${xs.length})`);
28
+ for (const s of xs) {
29
+ lines.push('', `**${s.id}. ${s.title}** — ${s.type}${s.automate ? ', automate' : ', manual'}${s.covered_by ? ` · already covered by \`${s.covered_by}\`` : ''}`, ...(s.preconditions ? [`- Given: ${s.preconditions}`] : []), ...s.steps.map((step, i) => `${i + 1}. ${step}`), `- Expected: ${s.expected}`, `- Grounded on: ${s.grounded_on}`);
30
+ }
31
+ }
32
+ const automate = plan.scenarios.filter((s) => s.automate && !s.covered_by);
33
+ lines.push('', '## To automate next', '', automate.length ? automate.map((s) => `- ${s.id} ${s.title} (${s.priority})`).join('\n') : 'Nothing new: existing tests cover the automatable scenarios.');
34
+ if (plan.open_questions.length)
35
+ lines.push('', '## Open questions', ...plan.open_questions.map((q) => `- ${q}`));
36
+ lines.push('', '## What this plan was built from', `- Feature description: ${facts.feature.length > 200 ? `${facts.feature.slice(0, 200)}…` : facts.feature}`, `- Change: ${facts.change ? `${facts.change.range}, ${facts.change.files.length} file(s)` : 'none given'}`, `- Pages read: ${facts.pages.length ? facts.pages.map((p) => p.url).join(', ') : facts.pagesNote ?? 'none given'}`, `- Existing tests: ${facts.tests.tests.length} named in ${facts.tests.files} file(s)${facts.testSources?.length ? `; read in full: ${facts.testSources.map((t) => t.file).join(', ')}` : ''}`, `- Failure history: ${facts.history.length ? `${facts.history.length} test(s) Tewip diagnosed before` : 'none recorded yet'}`, '');
37
+ return lines.join('\n');
38
+ }
@@ -0,0 +1,119 @@
1
+ // Phase 6: test plans. Tewip writes a plan from facts it reads, not from imagination: the feature
2
+ // as described, what a change touched, the pages of the running app, the tests that already exist,
3
+ // and - what general agents do not have - the project's own failure history, so the plan weighs
4
+ // the areas that broke or flaked before. The model proposes; everything it may rely on is listed
5
+ // with the plan, and scenarios say what they are grounded on.
6
+ import { existsSync, readdirSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { readJsonl } from '../../reporters/store.js';
9
+ import { changeSummary, existingTests, readPages, relatedCodeSources, relatedTestSources } from './grounding.js';
10
+ export const PLAN_PROMPT_VERSION = 'plan-4';
11
+ /** Failures Tewip already diagnosed in this project (from `tewip explain` runs kept in .tewip). */
12
+ function failureHistory(root, cap = 60) {
13
+ const dir = join(root, '.tewip', 'history');
14
+ if (!existsSync(dir))
15
+ return [];
16
+ const seen = new Map();
17
+ for (const project of readdirSync(dir)) {
18
+ for (const r of readJsonl(join(dir, project, 'triage.jsonl'))) {
19
+ seen.set(r.testId, r.diagnosis?.category ?? 'UNKNOWN');
20
+ }
21
+ }
22
+ return [...seen].slice(-cap).map(([test, category]) => `${test}: ${category}`);
23
+ }
24
+ export async function gatherPlanFacts(input) {
25
+ const { pages, note } = await readPages(input.urls ?? [], input.root);
26
+ return {
27
+ feature: input.feature,
28
+ change: input.diffRange ? changeSummary(input.root, input.diffRange) : null,
29
+ pages,
30
+ pagesNote: note,
31
+ tests: existingTests(input.root),
32
+ testSources: relatedTestSources(input.root, input.feature),
33
+ codeSources: relatedCodeSources(input.root, input.feature),
34
+ history: failureHistory(input.root),
35
+ };
36
+ }
37
+ const SYSTEM = `You are Tewip's test planner, a senior QA lead. Write a test plan for the feature described.
38
+
39
+ Use only the facts given: the feature description, the change (files and diff), the pages of the running app (accessibility snapshots: the roles and names a user sees), the tests that already exist, and the project's failure history.
40
+ - Name page elements only as they appear in a snapshot. If a scenario needs something no snapshot shows, say so in grounded_on ("not seen on the pages read; confirm").
41
+ - Do not plan what an existing test already covers. Read existingTests.relatedSources: a table-driven test (a list of cases, test.each, parametrize) covers every case in its table. covered_by is either an entry copied exactly from existingTests.names, or a file from relatedSources followed by ": " and the exact case that covers the scenario (e.g. "test/query.test.ts: { input: '/?foo=1', query: { foo: undefined }, out: '/' }"), or an empty string. Partial or related coverage goes in grounded_on, never in covered_by.
42
+ - Read the code: do not expect behaviour that contradicts what the code documents (a comment saying keys are ignored, a value is clamped...). If the intended behaviour is unclear, ask in open_questions instead of assuming.
43
+ - Weigh the failure history: an area whose tests failed or flaked before deserves a scenario for that risk.
44
+ - Cover more than the happy path: negative cases, boundaries, accessibility, and security where the feature touches input, access or data.
45
+ - Prioritise: P0 breaks the feature for everyone, P1 breaks it for some or corrupts data, P2 is a lesser problem.
46
+ - Keep steps concrete and short, as a tester would run them. Mark automate true when a stable automated check is realistic.`;
47
+ const SCENARIO = {
48
+ type: 'object',
49
+ additionalProperties: false,
50
+ required: ['id', 'title', 'type', 'priority', 'preconditions', 'steps', 'expected', 'automate', 'covered_by', 'grounded_on'],
51
+ properties: {
52
+ id: { type: 'string' },
53
+ title: { type: 'string' },
54
+ type: { type: 'string', enum: ['functional', 'negative', 'boundary', 'accessibility', 'security', 'performance', 'compatibility'] },
55
+ priority: { type: 'string', enum: ['P0', 'P1', 'P2'] },
56
+ preconditions: { type: 'string' },
57
+ steps: { type: 'array', items: { type: 'string' } },
58
+ expected: { type: 'string' },
59
+ automate: { type: 'boolean' },
60
+ covered_by: { type: 'string' },
61
+ grounded_on: { type: 'string' },
62
+ },
63
+ };
64
+ const SCHEMA = {
65
+ type: 'object',
66
+ additionalProperties: false,
67
+ required: ['title', 'summary', 'scope', 'out_of_scope', 'risks', 'scenarios', 'open_questions'],
68
+ properties: {
69
+ title: { type: 'string' },
70
+ summary: { type: 'string' },
71
+ scope: { type: 'array', items: { type: 'string' } },
72
+ out_of_scope: { type: 'array', items: { type: 'string' } },
73
+ risks: {
74
+ type: 'array',
75
+ items: { type: 'object', additionalProperties: false, required: ['risk', 'why', 'severity'], properties: { risk: { type: 'string' }, why: { type: 'string' }, severity: { type: 'string', enum: ['high', 'medium', 'low'] } } },
76
+ },
77
+ scenarios: { type: 'array', items: SCENARIO },
78
+ open_questions: { type: 'array', items: { type: 'string' } },
79
+ },
80
+ };
81
+ function factsForModel(f) {
82
+ return JSON.stringify({
83
+ feature: f.feature,
84
+ change: f.change ? { range: f.change.range, files: f.change.files, diff: f.change.patch } : 'no change given',
85
+ pages: f.pages.length ? f.pages.map((p) => ({ url: p.url, title: p.title, accessibilitySnapshot: p.aria })) : f.pagesNote ?? 'no pages given',
86
+ existingTests: { files: f.tests.files, names: f.tests.tests, relatedSources: f.testSources },
87
+ code: f.codeSources?.length ? f.codeSources : 'not read',
88
+ failureHistory: f.history.length ? f.history : 'none recorded yet',
89
+ });
90
+ }
91
+ /**
92
+ * The model's "already covered by" claims are checked against the tests Tewip actually found: a
93
+ * claim naming no real test is dropped (and kept as context in grounded_on), so a scenario is never
94
+ * marked covered, and left out of what to automate, on the model's word alone.
95
+ */
96
+ export function verifyCoverage(plan, facts) {
97
+ const known = new Set(facts.tests.tests);
98
+ const sources = facts.testSources ?? [];
99
+ // A cited case must appear in the file it names: its code-like parts, at least.
100
+ const inSource = (claim) => sources.some((src) => {
101
+ if (!claim.startsWith(`${src.file}: `))
102
+ return false;
103
+ const quoted = claim.slice(src.file.length + 2).match(/[A-Za-z0-9_$./?=&%+-]{3,}/g) ?? [];
104
+ return quoted.length > 0 && quoted.every((part) => src.content.includes(part));
105
+ });
106
+ return {
107
+ ...plan,
108
+ scenarios: plan.scenarios.map((s) => {
109
+ const claim = s.covered_by.trim();
110
+ if (!claim || known.has(claim) || inSource(claim))
111
+ return { ...s, covered_by: claim };
112
+ return { ...s, covered_by: '', grounded_on: `${s.grounded_on} (related, not a full cover: ${claim})` };
113
+ }),
114
+ };
115
+ }
116
+ export async function writePlan(facts, model) {
117
+ const raw = await model.complete({ system: SYSTEM, user: factsForModel(facts), schemaName: 'tewip_test_plan', schema: SCHEMA });
118
+ return verifyCoverage(JSON.parse(raw), facts);
119
+ }