tewip-ai 0.8.0 → 0.9.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
@@ -166,6 +166,24 @@ that lost its markup) and **5 of 5 harmless changes ignored** (capitals, extra s
166
166
  implemented as a link, reordered sections). No designer has judged the output yet, so the roadmap's
167
167
  own metric is still open.
168
168
 
169
+ ## Which flaky tests to quarantine, and what they cost (new)
170
+
171
+ ```bash
172
+ tewip flaky --days 30 # the dossier: quarantine, fix, or watch - with the evidence
173
+ tewip flaky --apply # writes the quarantine line into the test file
174
+ ```
175
+
176
+ Other tools count retries. Tewip already decided *why* each failure happened, so it can say: this
177
+ test failed in 161 of 647 runs **across 109 different branches** - that is the test, not your
178
+ change - and quarantining it gives the team back 148 CI minutes a week. It refuses to hide a test
179
+ that mostly fails (that one is broken, not flaky) and **never** quarantines a test whose failures
180
+ looked like a real bug.
181
+
182
+ Measured on three real projects' CI history (Gutenberg twice, Supabase): 76 tests proposed for
183
+ quarantine, **zero of them containing a single real-bug failure**, giving back roughly 34, 148 and
184
+ 1,178 CI minutes a week. It also tells you when a quarantined test has stopped failing and should
185
+ come back - the half nobody ever does.
186
+
169
187
  ## Bugs filed back, and test cases where your team keeps them (Phase 10, completed)
170
188
 
171
189
  ```bash
package/TEAM-GUIDE.md CHANGED
@@ -127,6 +127,7 @@ collects environment failures in one issue, and (optionally) opens a fix pull re
127
127
  | `tewip spec "<feature>"` | An epic, its user stories and acceptance criteria, grounded in your code; files them in Jira with `--project PROJ --create` | Before the work is broken down |
128
128
  | `tewip report report.xml --gate` | The CI command for GitLab, Jenkins, CircleCI, Azure, Bitbucket and the rest: diagnoses the run, comments on the GitLab merge request, and fails the build only on failures that need a person | Your CI is not GitHub Actions |
129
129
  | `tewip watch --cmd "npm test"` | Runs your tests when you save a file and explains each failure straight away | While you are writing code |
130
+ | `tewip flaky --days 30` | Which tests to quarantine, which to fix, what they cost in CI minutes - and when a quarantined one can come back | Monday morning |
130
131
  | `tewip note --days 7` | The weekly quality note for a lead, from runs already diagnosed (no model calls, free) | Monday morning |
131
132
  | `tewip fix --code report.xml` | Opt-in: proposes the smallest change to application code for a real bug, and keeps it only if the failing test passes and the whole suite still passes | "It is a real bug and I want a starting patch" |
132
133
  | `tewip design <figma link> --url <page>` | Compares a Figma frame against the running page: the words the design specifies and the controls its layer names imply | "Did we build what was designed?" |
package/dist/cli/tewip.js CHANGED
@@ -71,6 +71,11 @@ Usage:
71
71
  Beside your editor: runs your tests when a file changes (or
72
72
  watches for new reports) and explains each failure as it
73
73
  happens, without CI.
74
+ tewip flaky [--days 30] [--apply] [--name <project>] [--out flaky.md]
75
+ Which tests are wasting the team's time, how much CI time they
76
+ cost, and what to do with each: quarantine, fix, or watch. Read
77
+ from runs already diagnosed, so it costs nothing. --apply puts
78
+ the quarantine line in the test file with the evidence above it.
74
79
  tewip note [--days 7] [--name <project>] [--out note.md] [--post]
75
80
  The weekly quality note for a lead, written from the runs Tewip
76
81
  already diagnosed. No model calls, so it costs nothing.
@@ -254,6 +259,17 @@ async function main() {
254
259
  });
255
260
  return;
256
261
  }
262
+ case 'flaky': {
263
+ const { runFlaky } = await import('../engine/flaky/flaky-command.js');
264
+ await runFlaky({
265
+ root: process.cwd(),
266
+ name: flag(args, 'name'),
267
+ days: Math.max(1, Number(flag(args, 'days')) || 30),
268
+ apply: args.includes('--apply'),
269
+ out: flag(args, 'out'),
270
+ });
271
+ return;
272
+ }
257
273
  case 'note': {
258
274
  const { runNote } = await import('../engine/notify/note-command.js');
259
275
  await runNote({
@@ -0,0 +1,142 @@
1
+ // `tewip flaky`: the Monday morning answer. Which tests are wasting the team's time, how much, and
2
+ // what to do about each one - read from the runs Tewip already diagnosed, so it costs nothing and
3
+ // cannot re-judge an old failure with today's rules.
4
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
5
+ import { join, resolve } from 'node:path';
6
+ import { datasetFiles, readRunSummaries, readTriageRecords } from '../../reporters/store.js';
7
+ import { flakyTests, readyToRelease, weeklyCost } from './flaky.js';
8
+ import { applyQuarantine, frameworkOf, quarantineFor } from './quarantine.js';
9
+ const DAY_MS = 24 * 60 * 60 * 1000;
10
+ const minutes = (ms) => `${(ms / 60_000).toFixed(1)} CI min`;
11
+ function projects(root, only) {
12
+ const dir = join(root, '.tewip', 'history');
13
+ if (only)
14
+ return [only];
15
+ return existsSync(dir) ? readdirSync(dir).filter((name) => existsSync(join(dir, name, 'triage.jsonl'))) : [];
16
+ }
17
+ /** The test file on disk, when the report's path still exists in this project. */
18
+ function testFile(root, test) {
19
+ const direct = resolve(root, test.file);
20
+ return test.file && existsSync(direct) ? direct : null;
21
+ }
22
+ export async function runFlaky(cmd) {
23
+ const names = projects(cmd.root, cmd.name);
24
+ if (names.length === 0) {
25
+ throw new Error(`no history in ${join(cmd.root, '.tewip', 'history')}. Run \`tewip explain\` or \`tewip report\` on a few runs first: this is read from what those kept.`);
26
+ }
27
+ const since = new Date(Date.now() - cmd.days * DAY_MS).toISOString();
28
+ const all = [];
29
+ const lines = ['# Flaky tests', '', `The last ${cmd.days} days, from runs Tewip already diagnosed. No tests were run and no model was called.`, ''];
30
+ for (const name of names) {
31
+ const history = datasetFiles(join(cmd.root, '.tewip', 'history', name));
32
+ const records = readTriageRecords(history.triage);
33
+ const runs = readRunSummaries(history.runs);
34
+ const tests = flakyTests(records, runs, { since });
35
+ if (tests.length === 0)
36
+ continue;
37
+ all.push(...tests);
38
+ const cost = weeklyCost(tests, cmd.days);
39
+ lines.push(`## ${name}`, '', `${tests.length} test(s) failed at least once. About ${cost.minutes} CI minute(s) and ${cost.attempts} failed attempt(s) a week.`, '');
40
+ const group = (verdict, title, explain) => {
41
+ const chosen = tests.filter((t) => t.verdict === verdict);
42
+ if (chosen.length === 0)
43
+ return;
44
+ lines.push(`### ${title} (${chosen.length})`, '', explain, '');
45
+ for (const test of chosen.slice(0, 15)) {
46
+ lines.push(`- **${test.title}**`);
47
+ lines.push(` - ${test.failedRuns} of ${test.totalRuns} run(s), ${test.failedAttempts} failed attempt(s), ${minutes(test.wastedMs)} lost${test.recoveredOnRetry > 0 ? `, passed on a retry ${test.recoveredOnRetry} time(s)` : ''}`);
48
+ lines.push(` - Tewip read them as: ${test.categories.map((c) => `${c.category} ×${c.count}`).join(', ')}`);
49
+ lines.push(` - Why: ${test.why}`);
50
+ if (test.file)
51
+ lines.push(` - Where: ${test.file}`);
52
+ }
53
+ lines.push('');
54
+ };
55
+ group('quarantine', 'Quarantine these', 'Unreliable rather than wrong. Stop them blocking everyone, then fix them on purpose.');
56
+ group('fix-now', 'Fix these, do not hide them', 'Hiding one of these would hide something real.');
57
+ group('watch', 'Watch', 'Not enough evidence yet to act.');
58
+ // Coming back out is the half nobody ever does.
59
+ const quarantined = readQuarantined(cmd.root);
60
+ const back = readyToRelease(quarantined, records, runs, since);
61
+ if (back.length > 0) {
62
+ lines.push('### Ready to come out of quarantine', '', 'These ran in this period and did not fail once.', '');
63
+ for (const testId of back)
64
+ lines.push(`- ${testId.split(' > ').at(-1)} — take its quarantine line out and let it block again.`);
65
+ lines.push('');
66
+ }
67
+ }
68
+ if (all.length === 0) {
69
+ console.log(`Nothing failed in the last ${cmd.days} days. Try --days 30.`);
70
+ return [];
71
+ }
72
+ const toQuarantine = all.filter((t) => t.verdict === 'quarantine');
73
+ if (cmd.apply && toQuarantine.length > 0)
74
+ lines.push(...applyAll(cmd.root, toQuarantine));
75
+ else if (toQuarantine.length > 0)
76
+ lines.push(...suggest(cmd.root, toQuarantine));
77
+ const report = lines.join('\n');
78
+ console.log(report);
79
+ if (cmd.out) {
80
+ writeFileSync(resolve(cmd.out), `${report}\n`, 'utf8');
81
+ console.log(`\nWritten to ${cmd.out}`);
82
+ }
83
+ return all;
84
+ }
85
+ /** Tests already carrying Tewip's quarantine line, found by reading the test files it knows about. */
86
+ function readQuarantined(root) {
87
+ const file = join(root, '.tewip', 'quarantined.txt');
88
+ return existsSync(file) ? readFileSync(file, 'utf8').split('\n').map((l) => l.trim()).filter(Boolean) : [];
89
+ }
90
+ function suggest(root, tests) {
91
+ const lines = ['### The line to add', ''];
92
+ for (const test of tests.slice(0, 5)) {
93
+ const path = testFile(root, test);
94
+ const framework = path ? frameworkOf(path, readFileSync(path, 'utf8')) : null;
95
+ if (!path || !framework) {
96
+ lines.push(`- ${test.title}: ${test.file || 'file not found in this project'} - quarantine it the way this project does.`);
97
+ continue;
98
+ }
99
+ lines.push(`- ${test.title} (${test.file}):`, '```', quarantineFor(test, framework).line, '```');
100
+ }
101
+ lines.push('', 'Run `tewip flaky --apply` to put these lines in, each with the evidence above it. Only the named tests are touched.', '');
102
+ return lines;
103
+ }
104
+ /** Writes the quarantine into the test files, and remembers which tests were quarantined. */
105
+ function applyAll(root, tests) {
106
+ const lines = ['### Applied', ''];
107
+ const done = [];
108
+ for (const test of tests) {
109
+ const path = testFile(root, test);
110
+ if (!path) {
111
+ lines.push(`- ${test.title}: not applied, ${test.file || 'no file'} is not in this project`);
112
+ continue;
113
+ }
114
+ const source = readFileSync(path, 'utf8');
115
+ const framework = frameworkOf(path, source);
116
+ if (!framework) {
117
+ lines.push(`- ${test.title}: not applied, the framework of ${test.file} was not recognised`);
118
+ continue;
119
+ }
120
+ const changed = applyQuarantine(source, test, framework);
121
+ if (!changed) {
122
+ lines.push(`- ${test.title}: not applied, the test was not found in ${test.file} (was it renamed?)`);
123
+ continue;
124
+ }
125
+ if (changed === source) {
126
+ lines.push(`- ${test.title}: already quarantined`);
127
+ done.push(test.testId);
128
+ continue;
129
+ }
130
+ writeFileSync(path, changed, 'utf8');
131
+ done.push(test.testId);
132
+ lines.push(`- ${test.title}: quarantined in ${test.file}`);
133
+ }
134
+ if (done.length > 0) {
135
+ const file = join(root, '.tewip', 'quarantined.txt');
136
+ const before = readQuarantined(root);
137
+ writeFileSync(file, [...new Set([...before, ...done])].join('\n') + '\n', 'utf8');
138
+ lines.push('', `Remembered in ${join('.tewip', 'quarantined.txt')}, so Tewip can tell you when each one is safe to bring back.`);
139
+ }
140
+ lines.push('', 'Nothing else in those files was changed. Review the diff before committing: a quarantined test is a decision, not a fix.', '');
141
+ return lines;
142
+ }
@@ -0,0 +1,104 @@
1
+ const titleOf = (testId) => testId.split(' > ').at(-1) ?? testId;
2
+ const fileOf = (testId, record) => record?.file ?? testId.split(' > ').find((p) => /\.(spec|test)\./.test(p)) ?? '';
3
+ /**
4
+ * A test's story across the kept runs. `records` are the failures Tewip diagnosed; `runs` say how
5
+ * often each test ran at all, which is what turns a count into a rate.
6
+ */
7
+ export function flakyTests(records, runs, options = {}) {
8
+ const { since = '', minRuns = 2, alwaysFailsAbove = 0.8 } = options;
9
+ const inWindow = runs.filter((r) => (r.startedAt ?? '') >= since);
10
+ const runIds = new Set(inWindow.map((r) => r.runId));
11
+ const branchOf = new Map(inWindow.map((r) => [r.runId, r.branch ?? '']));
12
+ const failures = records.filter((r) => runIds.has(r.runId) || (!r.runId && !since));
13
+ const ranIn = new Map();
14
+ for (const run of inWindow)
15
+ for (const testId of Object.keys(run.attempts ?? {}))
16
+ ranIn.set(testId, (ranIn.get(testId) ?? 0) + 1);
17
+ const byTest = new Map();
18
+ for (const record of failures)
19
+ byTest.set(record.testId, [...(byTest.get(record.testId) ?? []), record]);
20
+ const out = [];
21
+ for (const [testId, all] of byTest) {
22
+ const runsFailed = new Set(all.map((r) => r.runId));
23
+ // A retry that passed: the same test, same run, a later attempt that is not in the failures.
24
+ const attemptsByRun = new Map();
25
+ for (const r of all)
26
+ attemptsByRun.set(r.runId, [...(attemptsByRun.get(r.runId) ?? []), r]);
27
+ let recovered = 0;
28
+ for (const [runId, attempts] of attemptsByRun) {
29
+ const ran = inWindow.find((run) => run.runId === runId)?.attempts?.[testId] ?? attempts.length;
30
+ if (ran > attempts.length)
31
+ recovered++;
32
+ }
33
+ const counts = new Map();
34
+ for (const r of all)
35
+ counts.set(r.diagnosis?.category ?? 'UNKNOWN', (counts.get(r.diagnosis?.category ?? 'UNKNOWN') ?? 0) + 1);
36
+ const categories = [...counts].map(([category, count]) => ({ category, count })).sort((a, b) => b.count - a.count);
37
+ const times = all.map((r) => r.timestamp).sort();
38
+ const branches = [...new Set([...runsFailed].map((id) => branchOf.get(id) ?? '').filter(Boolean))];
39
+ const totalRuns = ranIn.get(testId) ?? runsFailed.size;
40
+ const test = {
41
+ testId,
42
+ title: titleOf(testId),
43
+ file: fileOf(testId, all[0]),
44
+ failedRuns: runsFailed.size,
45
+ totalRuns,
46
+ failedAttempts: all.length,
47
+ wastedMs: all.reduce((sum, r) => sum + (r.duration ?? 0), 0),
48
+ recoveredOnRetry: recovered,
49
+ branches,
50
+ categories,
51
+ firstSeen: times[0] ?? '',
52
+ lastSeen: times.at(-1) ?? '',
53
+ verdict: 'watch',
54
+ why: '',
55
+ };
56
+ Object.assign(test, decide(test, { minRuns, alwaysFailsAbove }));
57
+ out.push(test);
58
+ }
59
+ return out.sort((a, b) => b.wastedMs - a.wastedMs || b.failedRuns - a.failedRuns);
60
+ }
61
+ const share = (test) => (test.totalRuns > 0 ? test.failedRuns / test.totalRuns : 1);
62
+ const flakyShare = (test) => {
63
+ const flaky = test.categories.find((c) => c.category === 'TIMING')?.count ?? 0;
64
+ return test.failedAttempts > 0 ? flaky / test.failedAttempts : 0;
65
+ };
66
+ /**
67
+ * The decision, and why. Three rules, all conservative:
68
+ * a test that mostly fails is broken, not flaky - hiding it hides a real problem;
69
+ * a test whose failures Tewip read as the application being wrong is never quarantined;
70
+ * quarantine needs the test to have failed in several runs, and to be unreliable rather than
71
+ * consistently wrong.
72
+ */
73
+ function decide(test, { minRuns, alwaysFailsAbove }) {
74
+ const rate = share(test);
75
+ const percent = `${Math.round(rate * 100)}% of the ${test.totalRuns} run(s) it was in`;
76
+ const realBugs = test.categories.find((c) => c.category === 'REAL_BUG')?.count ?? 0;
77
+ if (realBugs > 0 && realBugs >= test.failedAttempts / 2) {
78
+ return { verdict: 'fix-now', why: `${realBugs} of its ${test.failedAttempts} failures looked like the application being wrong, not the test. Quarantining it would hide a bug.` };
79
+ }
80
+ if (rate > alwaysFailsAbove && test.totalRuns >= minRuns) {
81
+ return { verdict: 'fix-now', why: `it fails in ${percent}. That is not flakiness, it is a test that does not work - hiding it would hide whatever it is telling you.` };
82
+ }
83
+ if (test.failedRuns >= minRuns && flakyShare(test) >= 0.5) {
84
+ const spread = test.branches.length > 1 ? ` It failed on ${test.branches.length} different branches, so it is the test rather than one person's change.` : '';
85
+ return { verdict: 'quarantine', why: `it failed in ${percent}, and Tewip read most of those as the test being unreliable.${spread}` };
86
+ }
87
+ return { verdict: 'watch', why: `it failed in ${percent}: not often enough, or not consistently enough one thing, to act on yet.` };
88
+ }
89
+ /** What a week of this costs, so the number means something to whoever pays for CI. */
90
+ export function weeklyCost(tests, days) {
91
+ const wasted = tests.reduce((sum, t) => sum + t.wastedMs, 0);
92
+ const attempts = tests.reduce((sum, t) => sum + t.failedAttempts, 0);
93
+ const weeks = Math.max(days / 7, 0.1);
94
+ return { minutes: Math.round(wasted / 60_000 / weeks), attempts: Math.round(attempts / weeks) };
95
+ }
96
+ /**
97
+ * Tests that were quarantined and have stopped failing. Nobody ever takes a test out of quarantine,
98
+ * so this looks for the ones that have earned it: no failure in the window, and they ran.
99
+ */
100
+ export function readyToRelease(quarantined, records, runs, since) {
101
+ const failed = new Set(records.filter((r) => r.timestamp >= since).map((r) => r.testId));
102
+ const ran = new Set(runs.filter((r) => (r.startedAt ?? '') >= since).flatMap((r) => Object.keys(r.attempts ?? {})));
103
+ return quarantined.filter((testId) => ran.has(testId) && !failed.has(testId));
104
+ }
@@ -0,0 +1,77 @@
1
+ /** Which framework a test file belongs to, from its name and its contents. */
2
+ export function frameworkOf(file, source) {
3
+ if (/\.py$/.test(file))
4
+ return 'pytest';
5
+ if (/\.java$|\.kt$/.test(file))
6
+ return 'junit';
7
+ if (/\.cs$/.test(file))
8
+ return 'dotnet';
9
+ if (/_test\.go$/.test(file))
10
+ return 'go';
11
+ if (/@playwright\/test|from ['"]playwright/.test(source))
12
+ return 'playwright';
13
+ if (/from ['"]vitest['"]|\bvi\./.test(source))
14
+ return 'vitest';
15
+ if (/@jest\/globals|jest\./.test(source))
16
+ return 'jest';
17
+ if (/\brequire\(['"]mocha|describe\(/.test(source))
18
+ return 'mocha';
19
+ return /\.[cm]?[jt]sx?$/.test(file) ? 'jest' : null;
20
+ }
21
+ const MARK = {
22
+ playwright: (title) => `test.fixme(true, ${JSON.stringify(`quarantined by Tewip: ${title}`)});`,
23
+ jest: () => 'test.skip',
24
+ vitest: () => 'test.skip',
25
+ mocha: () => 'it.skip',
26
+ pytest: (title) => `@pytest.mark.skip(reason=${JSON.stringify(`quarantined by Tewip: ${title}`)})`,
27
+ junit: (title) => `@Disabled(${JSON.stringify(`quarantined by Tewip: ${title}`)})`,
28
+ dotnet: (title) => `[Fact(Skip = ${JSON.stringify(`quarantined by Tewip: ${title}`)})]`,
29
+ go: () => 't.Skip("quarantined by Tewip")',
30
+ };
31
+ /** What to write, and the note that goes above it. */
32
+ export function quarantineFor(test, framework) {
33
+ const rate = test.totalRuns > 0 ? `${test.failedRuns} of ${test.totalRuns} runs` : `${test.failedRuns} run(s)`;
34
+ return {
35
+ line: MARK[framework](test.title),
36
+ comment: [
37
+ `Quarantined by Tewip on ${new Date().toISOString().slice(0, 10)}: failed in ${rate}, ${Math.round(test.wastedMs / 60_000)} CI minute(s) lost.`,
38
+ test.branches.length > 1 ? `It failed on ${test.branches.length} branches, so it is the test and not one change.` : '',
39
+ 'Take this line out when the cause is fixed. `tewip flaky` will say when it has stopped failing.',
40
+ ].filter(Boolean),
41
+ };
42
+ }
43
+ /**
44
+ * The file with the test quarantined, or null when the test could not be found in it. Only the one
45
+ * test named is touched; everything else in the file stays byte for byte as it was.
46
+ */
47
+ export function applyQuarantine(source, test, framework) {
48
+ const nl = source.includes('\r\n') ? '\r\n' : '\n';
49
+ const lines = source.split(nl);
50
+ const quoted = test.title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
51
+ const declaration = new RegExp(`^(\\s*)((?:async\\s+)?(?:export\\s+)?(?:test|it|def test_|public\\s+void|func\\s+Test)\\b[^\\n]*${quoted})`, 'i');
52
+ const index = lines.findIndex((line) => declaration.test(line) || line.includes(`"${test.title}"`) || line.includes(`'${test.title}'`));
53
+ if (index < 0)
54
+ return null;
55
+ if (lines.slice(Math.max(0, index - 4), index + 1).some((l) => /quarantined by tewip/i.test(l)))
56
+ return source; // already done
57
+ const indent = (lines[index].match(/^\s*/) ?? [''])[0];
58
+ const { line, comment } = quarantineFor(test, framework);
59
+ const commentMark = framework === 'pytest' ? '#' : '//';
60
+ const note = comment.map((c) => `${indent}${commentMark} ${c}`);
61
+ // Where the mark goes differs: an annotation sits above the test, a skip replaces the call.
62
+ if (framework === 'jest' || framework === 'vitest' || framework === 'mocha') {
63
+ const replaced = lines[index].replace(/\b(test|it)(\.\w+)?\(/, `${line}(`);
64
+ if (replaced === lines[index])
65
+ return null;
66
+ return [...lines.slice(0, index), ...note, replaced, ...lines.slice(index + 1)].join(nl);
67
+ }
68
+ if (framework === 'playwright') {
69
+ // Playwright's fixme goes inside the test body, so the test still reports itself as skipped.
70
+ const body = lines[index].includes('=>') ? index : -1;
71
+ if (body < 0)
72
+ return null;
73
+ return [...lines.slice(0, index), ...note, lines[index], `${indent} ${line}`, ...lines.slice(index + 1)].join(nl);
74
+ }
75
+ // pytest, JUnit, .NET: the annotation goes on its own line above the test.
76
+ return [...lines.slice(0, index), ...note, `${indent}${line}`, ...lines.slice(index)].join(nl);
77
+ }
@@ -14,14 +14,41 @@ const MAX_ATTEMPTS = 3;
14
14
  /** TEWIP_LLM_CACHE moves the cache, e.g. into the project the command line runs in. */
15
15
  const CACHE_FILE = process.env.TEWIP_LLM_CACHE || join(DATA_DIR, 'llm-cache.jsonl');
16
16
  let envLoaded = false;
17
- /** Loads <project>/.env once. Variables already set in the environment win. */
17
+ /**
18
+ * Loads a `.env` once, from the project being worked on.
19
+ *
20
+ * This used to read only Tewip's own folder, which is right when running from a checkout and wrong
21
+ * for everybody who installs it: their key sits in their project's .env, and Tewip silently found
22
+ * no model and answered with rules alone. The working directory is now looked at first, then its
23
+ * parent (a monorepo package), then Tewip's own folder for development.
24
+ *
25
+ * Variables already set in the environment always win: CI sets real secrets, a file must not
26
+ * quietly replace them.
27
+ */
18
28
  export function loadDotEnv() {
19
29
  if (envLoaded)
20
30
  return;
21
31
  envLoaded = true;
22
- const file = join(PROJECT_ROOT, '.env');
23
- if (existsSync(file))
24
- process.loadEnvFile(file);
32
+ const places = [process.cwd(), join(process.cwd(), '..'), PROJECT_ROOT];
33
+ for (const dir of places) {
34
+ const file = join(dir, '.env');
35
+ if (!existsSync(file))
36
+ continue;
37
+ const before = new Set(Object.keys(process.env));
38
+ const kept = {};
39
+ for (const name of before)
40
+ kept[name] = process.env[name];
41
+ try {
42
+ process.loadEnvFile(file);
43
+ }
44
+ catch {
45
+ continue; // an unreadable or malformed file is not a reason to stop
46
+ }
47
+ for (const [name, value] of Object.entries(kept))
48
+ if (value !== undefined)
49
+ process.env[name] = value;
50
+ return;
51
+ }
25
52
  }
26
53
  export function openAiClient({ apiKey, model, reasoningEffort, baseUrl = 'https://api.openai.com/v1' }) {
27
54
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tewip-ai",
3
- "version": "0.8.0",
3
+ "version": "0.9.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",