tewip-ai 0.12.0 → 0.13.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
@@ -185,6 +185,8 @@ defects were found before release - a Robot timestamp that is not a date, k6's n
185
185
 
186
186
  ```bash
187
187
  tewip build "naturalsize should take a sep argument that goes between the number and the unit"
188
+ tewip build --issue PROJ-123 # build from the ticket's own description and acceptance criteria
189
+ tewip build "..." --pr # commit the proved work on its own branch, open a draft PR
188
190
  tewip build "..." --dry-run # prove it, show the patch, leave nothing on disk
189
191
  ```
190
192
 
@@ -199,6 +201,12 @@ TypeScript, $0.18) and a `sep` argument to **python-humanize** (2 lines, Python,
199
201
  its own failing-then-passing test and the project's whole suite still green. Asked for something
200
202
  that already worked, it stopped after one model call and kept nothing.
201
203
 
204
+ It also refuses a feature that does not belong here at all. Handed a real Apache ticket about a
205
+ Flink HTTP sink, a URL library got an "HTTP sink" built into it - test passing, suite green, every
206
+ proof rule held, because when Tewip writes *both* the test and the code it can always make a
207
+ self-consistent pair pass. So before writing anything it now asks whether the words of the feature
208
+ mean anything in this repository, and refuses for free when they do not.
209
+
202
210
  The old promise was *"it can't touch your code"*. The new one is *"it can't change your code without
203
211
  showing you it works"*.
204
212
 
package/dist/cli/tewip.js CHANGED
@@ -67,11 +67,14 @@ Usage:
67
67
  the GitLab merge request, tells Slack or Teams only when a
68
68
  failure needs a person, and with --gate fails the build only on
69
69
  those failures.
70
- tewip build "<what it should do>" [--dry-run] [--repairs 2] [--out feature.patch]
70
+ tewip build "<what it should do>" | --issue PROJ-123 [--dry-run] [--pr] [--out feature.patch]
71
71
  Writes a feature, test first: the test must fail without the
72
72
  code and pass with it, and every test that was passing must
73
73
  still pass - otherwise everything goes back byte for byte.
74
- Nothing is committed.
74
+ --issue PROJ-123 Build from the ticket itself: its description and acceptance
75
+ criteria. Works with a public tracker without an account.
76
+ --pr Commit the proved work on a branch of its own and open a draft
77
+ pull request. Never merges, never commits to your branch.
75
78
  tewip review [--diff main...HEAD] [--out review.md]
76
79
  Before anyone reads the diff: which tests cover this change,
77
80
  which of them have flaked before, what has gone wrong in these
@@ -262,9 +265,24 @@ async function main() {
262
265
  return;
263
266
  }
264
267
  case 'build': {
265
- const feature = args.filter((a, i) => !a.startsWith('--') && !['--repairs', '--out', '--max-lines'].includes(args[i - 1] ?? '')).join(' ').trim();
268
+ const typed = args.filter((a, i) => !a.startsWith('--') && !['--repairs', '--out', '--max-lines', '--issue'].includes(args[i - 1] ?? '')).join(' ').trim();
269
+ const issueKey = flag(args, 'issue');
270
+ let feature = typed;
271
+ let issueTitle = '';
272
+ if (issueKey) {
273
+ // A ticket says what was asked for better than a sentence typed twice.
274
+ const { Jira, jiraFromEnv, jiraHelp } = await import('../engine/work/jira.js');
275
+ const { featureFromIssue } = await import('../engine/work/jira-command.js');
276
+ const config = jiraFromEnv();
277
+ if (!config)
278
+ throw new Error(jiraHelp());
279
+ const issue = await new Jira(config).issue(issueKey);
280
+ console.error(`Read ${issue.key} (${issue.type}, ${issue.status})${issue.acceptanceCriteria ? ' with its acceptance criteria' : ''}.`);
281
+ issueTitle = `${issue.key}: ${issue.summary}`;
282
+ feature = [featureFromIssue(issue), typed].filter(Boolean).join('\n\n');
283
+ }
266
284
  if (!feature)
267
- throw new Error('usage: tewip build "<what the feature should do>" [--dry-run] [--repairs 2] [--out feature.patch]');
285
+ throw new Error('usage: tewip build "<what the feature should do>" | --issue PROJ-123 [--dry-run] [--pr] [--out feature.patch]');
268
286
  const { runBuild, renderBuild } = await import('../engine/build/build-command.js');
269
287
  const dryRun = args.includes('--dry-run');
270
288
  const result = await runBuild({
@@ -276,9 +294,27 @@ async function main() {
276
294
  out: flag(args, 'out'),
277
295
  });
278
296
  console.log(`
279
- ${renderBuild(result, feature, dryRun)}`);
280
- if (result.status !== 'built')
297
+ ${renderBuild(result, issueTitle || feature, dryRun)}`);
298
+ if (result.status !== 'built') {
281
299
  process.exitCode = 1;
300
+ return;
301
+ }
302
+ if (args.includes('--pr')) {
303
+ if (dryRun)
304
+ throw new Error('--pr and --dry-run ask for opposite things: one keeps the work, the other puts it back.');
305
+ const { proposePullRequest } = await import('../engine/build/propose-pr.js');
306
+ const proposed = proposePullRequest({
307
+ root: process.cwd(),
308
+ files: result.files,
309
+ title: (issueTitle || feature.split('\n')[0]).slice(0, 72),
310
+ body: result.why,
311
+ issue: issueKey,
312
+ });
313
+ console.log(`
314
+ ${proposed.status === 'opened' ? 'Opened a draft pull request.' : proposed.status === 'committed' ? `Committed on ${proposed.branch}.` : 'No pull request was opened.'}`);
315
+ for (const line of proposed.next)
316
+ console.log(` ${line}`);
317
+ }
282
318
  return;
283
319
  }
284
320
  case 'review': {
@@ -53,6 +53,57 @@ export function definingSources(root, feature, limit = 3) {
53
53
  }
54
54
  return scored.sort((a, b) => b.score - a.score).slice(0, limit).map((s) => s.file);
55
55
  }
56
+ /**
57
+ * Does this feature belong in this project at all?
58
+ *
59
+ * Found by trying it: a real Apache ticket about a Flink HTTP sink was handed to a URL library, and
60
+ * Tewip built an "HTTP sink" into it - 28 lines, its own test passing, the suite green. Every proof
61
+ * rule held, because when Tewip writes both the test and the code it can always make a
62
+ * self-consistent pair pass. Nothing else referenced the invented API, so nothing else could break.
63
+ *
64
+ * The missing check is the obvious one: the words of the feature have to mean something here. A
65
+ * project's own vocabulary is its file names and what it exports, and a feature that shares almost
66
+ * none of it is a feature for some other repository.
67
+ */
68
+ export function belongsHere(root, feature, sources, testNames) {
69
+ const stop = new Set(['should', 'when', 'with', 'that', 'this', 'from', 'into', 'have', 'been', 'will', 'must', 'also', 'able', 'given', 'then', 'there', 'their', 'which', 'while', 'where', 'after', 'before', 'value', 'values', 'return', 'returns', 'support', 'supports', 'accept', 'accepts', 'option', 'options', 'default', 'behaviour', 'behavior', 'feature', 'example', 'instead', 'without']);
70
+ const words = (text) => new Set((text.toLowerCase().match(/[a-z][a-z0-9]{3,}/g) ?? []).filter((w) => !stop.has(w)));
71
+ const asked = [...words(feature)];
72
+ if (asked.length < 3)
73
+ return { ok: true, why: '' };
74
+ const vocabulary = new Set();
75
+ for (const s of sources)
76
+ for (const w of words(`${s.file} ${s.content.slice(0, 20_000)}`))
77
+ vocabulary.add(w);
78
+ for (const name of testNames)
79
+ for (const w of words(name))
80
+ vocabulary.add(w);
81
+ // The names of a project's files are not its vocabulary: "naturalsize" lives inside filesize.py,
82
+ // not in its name, and judging by names alone refused a feature that really did belong here.
83
+ let read = 0;
84
+ for (const file of sourceFiles(root, 1200)) {
85
+ if (!CODE_FILE.test(file) || read >= 60)
86
+ continue;
87
+ read++;
88
+ try {
89
+ for (const w of words(`${basename(file)} ${readFileSync(file, 'utf8').slice(0, 20_000)}`))
90
+ vocabulary.add(w);
91
+ }
92
+ catch {
93
+ // unreadable here is simply one less file of vocabulary
94
+ }
95
+ }
96
+ if (vocabulary.size === 0)
97
+ return { ok: true, why: '' };
98
+ const shared = asked.filter((w) => vocabulary.has(w));
99
+ const share = shared.length / asked.length;
100
+ if (share >= 0.2)
101
+ return { ok: true, why: '' };
102
+ return {
103
+ ok: false,
104
+ why: `almost nothing in this project mentions what that describes (${shared.length} of ${asked.length} of its words appear here${shared.length ? `: ${shared.slice(0, 5).join(', ')}` : ''}). If this is the right repository, say the feature in its own words - the names of the functions and files it should change. Tewip can make a test of its own invention pass in any project, and that would prove nothing.`,
105
+ };
106
+ }
56
107
  /** A test file name in the project's own shape, that does not collide with an existing one. */
57
108
  function testPathFor(framework, feature) {
58
109
  const stem = feature.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').split('-').slice(0, 4).join('-') || 'feature';
@@ -88,6 +139,10 @@ export async function runBuild(cmd) {
88
139
  testNames: tests.tests,
89
140
  };
90
141
  const log = [`framework: ${framework.label}`, `source files read: ${ctx.sources.map((s) => s.file).join(', ') || 'none found'}`];
142
+ // Before anything is written: is this even a feature for this repository?
143
+ const belongs = belongsHere(root, cmd.feature, ctx.sources, ctx.testNames);
144
+ if (!belongs.ok)
145
+ return { status: 'not-this-project', why: belongs.why, files: [], diff: '', log: [...log, belongs.why] };
91
146
  const workspace = new Workspace(root);
92
147
  const maxLines = cmd.maxLines ?? 120;
93
148
  const stop = (status, why) => {
@@ -0,0 +1,88 @@
1
+ // Turning proved work into a pull request - the last step of the loop, and the one where Tewip
2
+ // touches somebody's repository, so it is the most careful.
3
+ //
4
+ // Three rules, enforced here rather than promised:
5
+ // it only ever pushes a new branch of its own, never yours;
6
+ // it never merges, and opens the request as a draft so nobody can merge it by reflex;
7
+ // it describes exactly what was proved and what was not, because a pull request nobody can check
8
+ // is worse than a patch file.
9
+ //
10
+ // The work must already be proved before anything reaches this file. Nothing here runs a test or
11
+ // decides anything; it commits what was verified and opens the request.
12
+ import { spawnSync } from 'node:child_process';
13
+ const run = (cwd, command, args) => {
14
+ const r = spawnSync(command, args, { cwd, encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 });
15
+ return { ok: r.status === 0, out: `${r.stdout ?? ''}${r.stderr ?? ''}`.trim() };
16
+ };
17
+ /** A branch name a person can read at a glance, and that cannot collide with an existing one. */
18
+ export function branchNameFor(title, now = new Date()) {
19
+ const stem = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').split('-').slice(0, 6).join('-') || 'change';
20
+ return `tewip/${stem}-${now.toISOString().slice(2, 16).replace(/\D/g, '')}`;
21
+ }
22
+ /** The pull request body: what was built, and precisely how far the proof goes. */
23
+ export function requestBody(body, files, issue) {
24
+ return [
25
+ body,
26
+ '',
27
+ `Files: ${files.map((f) => `\`${f}\``).join(', ')}`,
28
+ issue ? `From: ${issue}` : '',
29
+ '',
30
+ '---',
31
+ '',
32
+ 'Written by Tewip and kept only because it was proved: the test failed without this change and',
33
+ 'passes with it, and every test that was passing still passes.',
34
+ '',
35
+ 'What that does **not** say: whether this is the design you want, whether it is the right place',
36
+ 'for it, or whether it is complete. That is what your review is for. This is a draft on a branch',
37
+ 'of its own; Tewip never merges anything.',
38
+ ]
39
+ .filter((line) => line !== '')
40
+ .join('\n');
41
+ }
42
+ /**
43
+ * Commits the proved files on a fresh branch and opens a draft pull request with the `gh` CLI the
44
+ * developer is already logged into. When there is no `gh`, the branch is still pushed and the
45
+ * command to open the request by hand is printed - half a job done is better than a silent failure.
46
+ */
47
+ export function proposePullRequest(input) {
48
+ const { root, files } = input;
49
+ if (files.length === 0)
50
+ return { status: 'refused', next: ['there was nothing to commit'] };
51
+ if (!run(root, 'git', ['rev-parse', '--git-dir']).ok)
52
+ return { status: 'refused', next: ['this is not a git checkout, so there is no branch to open a request from'] };
53
+ const branch = branchNameFor(input.title);
54
+ const base = run(root, 'git', ['rev-parse', '--abbrev-ref', 'HEAD']).out.trim() || 'main';
55
+ // A branch of Tewip's own, from wherever the person is standing. Their branch is never committed to.
56
+ const made = run(root, 'git', ['checkout', '-b', branch]);
57
+ if (!made.ok)
58
+ return { status: 'refused', next: [`could not create the branch ${branch}: ${made.out.slice(0, 200)}`] };
59
+ const added = run(root, 'git', ['add', '--', ...files]);
60
+ if (!added.ok) {
61
+ run(root, 'git', ['checkout', base]);
62
+ return { status: 'refused', next: [`could not stage the files: ${added.out.slice(0, 200)}`] };
63
+ }
64
+ // Only Tewip's own files are committed: anything else the person had in progress stays theirs.
65
+ const committed = run(root, 'git', ['commit', '-m', input.title, '-m', requestBody(input.body, files, input.issue), '--only', '--', ...files]);
66
+ if (!committed.ok) {
67
+ run(root, 'git', ['checkout', base]);
68
+ return { status: 'refused', next: [`could not commit: ${committed.out.slice(0, 200)}`] };
69
+ }
70
+ const next = [];
71
+ const pushed = run(root, 'git', ['push', '-u', 'origin', branch]);
72
+ if (!pushed.ok) {
73
+ return {
74
+ status: 'committed',
75
+ branch,
76
+ next: [`the work is committed on ${branch}, but pushing failed: ${pushed.out.slice(0, 200)}`, `push it yourself with: git push -u origin ${branch}`],
77
+ };
78
+ }
79
+ const gh = run(root, 'gh', ['pr', 'create', '--draft', '--base', base, '--head', branch, '--title', input.title, '--body', requestBody(input.body, files, input.issue)]);
80
+ if (gh.ok) {
81
+ const url = (gh.out.match(/https:\/\/\S+/) ?? [])[0];
82
+ return { status: 'opened', branch, url, next: [`review it: ${url ?? branch}`, `back to where you were: git checkout ${base}`] };
83
+ }
84
+ next.push(`${branch} is pushed, but the pull request was not opened: ${gh.out.slice(0, 200)}`);
85
+ next.push(`open it with: gh pr create --draft --base ${base} --head ${branch}`);
86
+ next.push(`back to where you were: git checkout ${base}`);
87
+ return { status: 'committed', branch, next };
88
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tewip-ai",
3
- "version": "0.12.0",
3
+ "version": "0.13.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",