tewip-ai 0.40.0 → 0.42.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
@@ -576,6 +576,40 @@ With `--json` the verdict is **sealed**: editing `not-ready` to `ready` in the f
576
576
  fail, so a verdict that travels into a ticket or a release note can be checked rather than trusted. It is
577
577
  also an MCP tool, so an agent can ask whether its own work is done before saying that it is.
578
578
 
579
+ ## Tewip as a peer other agents must satisfy (Phase 34, new)
580
+
581
+ ```bash
582
+ tewip a2a # serves on 127.0.0.1:41241
583
+ ```
584
+
585
+ MCP lets an assistant call Tewip as a tool. **A2A is the other direction**: Tewip as an agent in its
586
+ own right, which another agent has to get past before its work counts as finished. That is the right
587
+ shape for a tool whose whole claim is proving work *written by a person or by an agent* — when most
588
+ code is written by agents, the thing that checks it has to be reachable by agents.
589
+
590
+ It serves the card at `/.well-known/agent-card.json` and answers JSON-RPC `SendMessage`, with eight
591
+ skills: triage, vouch, pick, schema, access, perf, standards and done.
592
+
593
+ **It asks rather than guesses.** Send "check my code" and you get back a question naming the eight
594
+ things Tewip answers — A2A has a state for exactly that, `TASK_STATE_INPUT_REQUIRED`:
595
+
596
+ ```
597
+ TASK_STATE_INPUT_REQUIRED
598
+ Tewip will not guess which question you are asking: that could be "Does this keep the
599
+ team's own rules?" or "Is this work actually finished?", and Tewip will not pick for you.
600
+ ```
601
+
602
+ **The guardrails are the design.** This is an HTTP port that runs commands against your repository:
603
+
604
+ - **Only read-only skills are reachable.** `fix`, `write`, `build`, `pursue` and `prove` are not on
605
+ the list — they change files or run your suite. A test asserts they never can be.
606
+ - **It listens on this machine alone** unless you pass `--host`, and says plainly what that means.
607
+ - **Every request is recorded** in `.tewip/a2a.jsonl`.
608
+
609
+ Its card declares `streaming: false` and `pushNotifications: false`, and those operations return an
610
+ error naming the field that says why. Declaring a capability you have not implemented is the one
611
+ thing an agent card must not do.
612
+
579
613
  ## Is this change slower? (Phase 30, new)
580
614
 
581
615
  ```bash
@@ -836,7 +870,7 @@ That last section is the part no SQL linter does: it searches your own code for
836
870
  away. "This drops `users.nickname`, and four files still read it" is a different sentence from "dropping a
837
871
  column is risky", and it is the one that stops the outage.
838
872
 
839
- It reads **SQL, Django, Rails, Alembic and TypeORM** migrations — which covers Prisma, Drizzle, Flyway
873
+ It reads **SQL, Django, Rails, Alembic, TypeORM, Sequelize, Knex, EF Core and Liquibase** migrations — which covers Prisma, Drizzle, Flyway
840
874
  and golang-migrate too, since those all emit plain `.sql`. It judges **only the forward path** (a rollback step
841
875
  contains the mirror image of the change, so reading it says a migration that creates a table destroys
842
876
  one), and stays quiet when the team already did the safe thing — `atomic = False`, `algorithm:
package/dist/cli/help.js CHANGED
@@ -157,9 +157,11 @@ Usage:
157
157
  What will this migration do to a live database? Names the changes
158
158
  that break the version still running during a deploy, destroy data,
159
159
  block writes while they run, or cannot be undone - and searches your
160
- code for what still uses the column it takes away. Reads SQL, Django,
161
- Rails and Alembic migrations, and says so when it cannot read one.
162
- No model, no cost.
160
+ code for what still uses the column it takes away. Reads SQL,
161
+ Django, Rails, Alembic, TypeORM, Sequelize, Knex, EF Core and
162
+ Liquibase - so Prisma, Drizzle, Flyway and golang-migrate too,
163
+ since those emit plain SQL - and says so when it cannot read one.
164
+ It judges only the forward path, never the rollback. No cost.
163
165
  tewip pick [--diff main...HEAD] [--list] [--out picked.md]
164
166
  Which tests could notice this change? Narrows a test run using
165
167
  facts rather than a prediction: a test runs when it imports a
@@ -212,6 +214,14 @@ Usage:
212
214
  it too. --verify says what it checked and what that does not
213
215
  establish - a digest means undamaged, not unedited. No cost.
214
216
  tewip mcp Serve Tewip over MCP (stdio) for an editor or AI assistant.
217
+ tewip a2a [--port 41241] [--host 127.0.0.1]
218
+ Answer other agents over A2A, so an agent that writes a diff has
219
+ a peer it must satisfy. Serves the agent card at
220
+ /.well-known/agent-card.json and takes JSON-RPC "SendMessage".
221
+ Only the read-only skills are reachable - nothing it exposes
222
+ writes a file - it listens on this machine alone unless you pass
223
+ --host, and every request is recorded in .tewip/a2a.jsonl. A
224
+ message it cannot place comes back as a question, not a guess.
215
225
  tewip --version Print the version.
216
226
 
217
227
  Reports it reads: Playwright, pytest/unittest, Jest, Vitest, Mocha, node:test, JUnit/TestNG
@@ -20,6 +20,19 @@ export async function proveVerb(verb, args) {
20
20
  });
21
21
  return true;
22
22
  }
23
+ case 'a2a': {
24
+ const { serveA2A } = await import('../engine/a2a/serve.js');
25
+ const { dirname, join } = await import('node:path');
26
+ serveA2A({
27
+ root: process.cwd(),
28
+ port: Number(flag(args, 'port') ?? '41241'),
29
+ // Loopback unless somebody asks for otherwise in so many words. The server runs commands
30
+ // against a repository and has no authentication, so the default must be the safe one.
31
+ host: flag(args, 'host') ?? '127.0.0.1',
32
+ tewipRoot: join(dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1')), '..'),
33
+ });
34
+ return true;
35
+ }
23
36
  case 'perf': {
24
37
  const { runPerf } = await import('../engine/perf/perf-command.js');
25
38
  const paths = args.filter((a, i) => !a.startsWith('--') && !['--out', '--label'].includes(args[i - 1] ?? ''));
@@ -0,0 +1,125 @@
1
+ // Phase 34: turning another agent's message into one of Tewip's verdicts.
2
+ //
3
+ // The interesting decision here is what to do when the message is vague, and the answer is the same
4
+ // one the rest of this project gives: ask rather than guess. A2A has a state for exactly that -
5
+ // TASK_STATE_INPUT_REQUIRED - so a caller who says "check my code" gets back a question naming the
6
+ // skills Tewip has, not a guess at which of eight things they meant. Guessing would make Tewip
7
+ // occasionally run a twenty-minute job nobody asked for, and occasionally answer the wrong question
8
+ // confidently, which is the failure this whole product exists to avoid.
9
+ import { spawnSync } from 'node:child_process';
10
+ import { existsSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { ANSWERABLE, skillById } from './card.js';
13
+ const str = (v) => (typeof v === 'string' ? v : '');
14
+ /** The words in a message, from its text parts. A2A puts the text directly on the part. */
15
+ export function textOf(message) {
16
+ if (!Array.isArray(message.parts))
17
+ return '';
18
+ return message.parts
19
+ .map((p) => (p && typeof p === 'object' ? str(p.text) : ''))
20
+ .filter((t) => t !== '')
21
+ .join(' ')
22
+ .trim();
23
+ }
24
+ /** A skill named outright, either in the message metadata or in a data part. */
25
+ function namedSkill(message) {
26
+ const meta = (message.metadata ?? {});
27
+ const fromMeta = str(meta.skillId ?? meta.skill);
28
+ if (fromMeta !== '')
29
+ return fromMeta;
30
+ if (!Array.isArray(message.parts))
31
+ return '';
32
+ for (const part of message.parts) {
33
+ if (!part || typeof part !== 'object')
34
+ continue;
35
+ const data = part.data;
36
+ if (data && typeof data === 'object') {
37
+ const named = str(data.skillId ?? data.skill);
38
+ if (named !== '')
39
+ return named;
40
+ }
41
+ }
42
+ return '';
43
+ }
44
+ /** How well a skill matches some words: its own tags and name, scored by how specific the word is. */
45
+ function scoreOf(skill, words) {
46
+ const haystack = `${skill.id} ${skill.verb} ${skill.name} ${skill.tags.join(' ')}`.toLowerCase();
47
+ let score = 0;
48
+ for (const word of words) {
49
+ if (word.length < 4)
50
+ continue;
51
+ if (haystack.includes(word))
52
+ score += word.length >= 7 ? 2 : 1;
53
+ }
54
+ return score;
55
+ }
56
+ /**
57
+ * Which skill a message is asking for.
58
+ *
59
+ * An id given outright always wins, because a calling agent that read the card should be taken at
60
+ * its word. Otherwise the words are matched, and a tie or a blank is a question rather than a coin
61
+ * toss: two skills scoring the same means Tewip genuinely does not know which was meant.
62
+ */
63
+ export function chooseSkill(message) {
64
+ const asked = textOf(message);
65
+ const named = namedSkill(message);
66
+ if (named !== '') {
67
+ const skill = skillById(named);
68
+ if (skill)
69
+ return { kind: 'skill', skill, text: asked };
70
+ return { kind: 'unknown', asked: named };
71
+ }
72
+ const words = asked.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
73
+ const scored = ANSWERABLE.map((skill) => ({ skill, score: scoreOf(skill, words) })).sort((a, b) => b.score - a.score);
74
+ const [best, next] = scored;
75
+ if (!best || best.score === 0) {
76
+ return { kind: 'ask', why: 'nothing in that message names one of the things Tewip answers' };
77
+ }
78
+ if (next && next.score === best.score) {
79
+ return { kind: 'ask', why: `that could be "${best.skill.name}" or "${next.skill.name}", and Tewip will not pick for you` };
80
+ }
81
+ return { kind: 'skill', skill: best.skill, text: asked };
82
+ }
83
+ /** The question Tewip asks back when it will not guess, listing what it can actually answer. */
84
+ export function askWhich(why) {
85
+ const list = ANSWERABLE.map((s) => `- \`${s.id}\` — ${s.name}`).join('\n');
86
+ return [
87
+ `Tewip will not guess which question you are asking: ${why}.`,
88
+ '',
89
+ 'Send the skill id in the message metadata as `skillId`, or say which of these you want:',
90
+ '',
91
+ list,
92
+ ].join('\n');
93
+ }
94
+ export function cliEntry() {
95
+ const here = import.meta.dirname;
96
+ for (const candidate of [join(here, '..', '..', 'cli', 'tewip.ts'), join(here, '..', '..', 'cli', 'tewip.js')]) {
97
+ if (existsSync(candidate))
98
+ return candidate;
99
+ }
100
+ return null;
101
+ }
102
+ /**
103
+ * Runs the verb and returns what it printed.
104
+ *
105
+ * The command line is the gate every one of these verdicts already goes through, so A2A reuses it
106
+ * rather than reaching into the engine: one code path means a verdict delivered to an agent cannot
107
+ * quietly differ from the same verdict delivered to a person.
108
+ */
109
+ export function runSkill(root, skill, args, timeoutMs = 10 * 60_000) {
110
+ const entry = cliEntry();
111
+ if (entry === null)
112
+ return { text: 'Tewip could not find its own command line, so it cannot answer.', failed: true };
113
+ const ran = spawnSync(process.execPath, [entry, skill.verb, ...args], {
114
+ cwd: root,
115
+ encoding: 'utf8',
116
+ timeout: timeoutMs,
117
+ maxBuffer: 32 * 1024 * 1024,
118
+ });
119
+ if (ran.error)
120
+ return { text: `Tewip could not run \`${skill.verb}\`: ${ran.error.message}`, failed: true };
121
+ const out = `${ran.stdout ?? ''}${ran.stderr ? `\n${ran.stderr}` : ''}`.trim();
122
+ // A non-zero exit is how the gating verbs report a real problem, so it is an answer rather than a
123
+ // failure. Only an empty answer means something actually went wrong.
124
+ return { text: out === '' ? `\`${skill.verb}\` produced no output.` : out, failed: out === '' };
125
+ }
@@ -0,0 +1,126 @@
1
+ // Phase 34: the agent card, so other agents can find out what Tewip will answer.
2
+ //
3
+ // A2A is the one capability on the standard list that matches this product's thesis exactly. Tewip
4
+ // exists to prove work "written by a person or by an agent", and MCP already lets an assistant call
5
+ // it as a tool. A2A is the other direction: Tewip as a peer that another agent has to satisfy
6
+ // before its diff counts as finished. When most code is written by agents, the thing that checks
7
+ // the work has to be reachable by agents.
8
+ //
9
+ // Every field here was taken from the v1.0 specification rather than from memory, because four
10
+ // details would have been wrong: discovery is `/.well-known/agent-card.json` and not `agent.json`;
11
+ // the JSON-RPC method is `SendMessage` in PascalCase, not `message/send`; a role is `ROLE_USER`,
12
+ // not `user`; and a task state is `TASK_STATE_COMPLETED`, not `completed`. Each of those is the
13
+ // kind of mistake that produces a server nothing can talk to.
14
+ import { readFileSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+ /**
17
+ * The verbs a caller down a socket is allowed to reach, and the single rule behind the list:
18
+ * **everything here reads and nothing here writes.**
19
+ *
20
+ * `fix`, `write`, `build` and `pursue` change files; `prove` deliberately breaks a file and runs the
21
+ * suite before putting it back. None of those belongs behind an HTTP port that another program can
22
+ * reach. The whole guardrail story in this project is an allowlist of what may be done rather than
23
+ * a blocklist of what may not, and this is the same allowlist applied to a new door.
24
+ */
25
+ export const ANSWERABLE = [
26
+ {
27
+ id: 'explain-failures',
28
+ verb: 'explain',
29
+ name: 'Why did these tests fail?',
30
+ description: 'Reads a test report and gives each failure a cause with the evidence for it: a real bug, a flaky test, the environment, the test data, or a changed selector. Answers "cannot say" rather than guessing.',
31
+ tags: ['testing', 'triage', 'ci', 'debugging'],
32
+ examples: ['Why did the checkout tests fail in this report?', 'Is this failure flaky or a real bug?'],
33
+ },
34
+ {
35
+ id: 'could-these-tests-fail',
36
+ verb: 'vouch',
37
+ name: 'Could these tests ever fail?',
38
+ description: 'Reads a test file and names the tests that check nothing: an assertion that compares a value with itself, an expectation left unfinished, a test with no assertion at all. A suite that cannot fail is not evidence.',
39
+ tags: ['testing', 'quality', 'review'],
40
+ examples: ['Can the tests in this file actually fail?'],
41
+ },
42
+ {
43
+ id: 'which-tests-notice',
44
+ verb: 'pick',
45
+ name: 'Which tests could notice this change?',
46
+ description: 'Narrows a test run from facts in the source, and refuses to narrow when it cannot show the rest are unaffected. Returns the files to run, or says it cannot be done safely.',
47
+ tags: ['testing', 'ci', 'performance'],
48
+ examples: ['Which tests do I need to run for this diff?'],
49
+ },
50
+ {
51
+ id: 'migration-safety',
52
+ verb: 'schema',
53
+ name: 'What will this migration do to a live database?',
54
+ description: 'Names what breaks the version still running during a rolling deploy, destroys data, blocks writes, or cannot be undone - and searches the repository for code that still uses a column being dropped. Reads SQL, Django, Rails, Alembic, TypeORM, Sequelize, Knex, EF Core and Liquibase, which covers Prisma, Drizzle and Flyway too.',
55
+ tags: ['database', 'migrations', 'deployment', 'risk'],
56
+ examples: ['Is this migration safe to deploy?', 'Will this drop break the running version?'],
57
+ },
58
+ {
59
+ id: 'accessibility',
60
+ verb: 'access',
61
+ name: 'Can everybody use this interface?',
62
+ description: 'Names the accessibility barriers that reading the markup proves, each against its WCAG 2.2 criterion and EN 301 549 clause, and keeps apart the ones only a person can settle. Always reports which criteria it could not check.',
63
+ tags: ['accessibility', 'wcag', 'compliance', 'frontend'],
64
+ examples: ['Is this component accessible?', 'What WCAG failures are in this page?'],
65
+ },
66
+ {
67
+ id: 'is-it-slower',
68
+ verb: 'perf',
69
+ name: 'Is this change slower?',
70
+ description: 'Judges a k6 or Lighthouse run against recorded baselines, and refuses to call a regression without at least three of them, because one run against one run cannot tell a change from a busy machine.',
71
+ tags: ['performance', 'benchmarking', 'regression'],
72
+ examples: ['Did this change make the checkout slower?'],
73
+ },
74
+ {
75
+ id: 'house-standards',
76
+ verb: 'standards',
77
+ name: "Does this keep the team's own rules?",
78
+ description: "Checks a change against the rules this team wrote down in .tewip/standards/, quoting the offending line and the team's own reason. Rules it enforces are kept apart from guidance it can only carry.",
79
+ tags: ['code-review', 'standards', 'conventions'],
80
+ examples: ['Does this change follow our conventions?'],
81
+ },
82
+ {
83
+ id: 'is-this-done',
84
+ verb: 'done',
85
+ name: 'Is this work actually finished?',
86
+ description: 'One verdict over a change, from layers that each have a measured accuracy: is anything failing, is the changed code tested by something that really reaches it, can those tests fail at all, and is what was asked for covered. Answers ready, not ready, or cannot say - and never rounds cannot say up to ready.',
87
+ tags: ['code-review', 'quality', 'verification'],
88
+ examples: ['Is this pull request ready to merge?', 'Is my change done?'],
89
+ },
90
+ ];
91
+ /** The version Tewip is running, so the card does not claim a version it is not. */
92
+ function versionOf(root) {
93
+ try {
94
+ const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
95
+ return typeof pkg.version === 'string' ? pkg.version : '0.0.0';
96
+ }
97
+ catch {
98
+ return '0.0.0';
99
+ }
100
+ }
101
+ /**
102
+ * The card, built from the same list the server answers from, so the two cannot drift. A card that
103
+ * advertises a skill the server will not perform is worse than no card.
104
+ */
105
+ export function agentCard(url, tewipRoot) {
106
+ return {
107
+ name: 'Tewip',
108
+ description: 'Proves software work, written by a person or by an agent. Triages a failing test to a cause with evidence, says whether a test could ever fail, reads what a migration will do to a live database, names who cannot use an interface, and gives one verdict on whether a change is done. It answers "cannot say" rather than guessing, and every answer carries what it did not check.',
109
+ supportedInterfaces: [{ url, transport: 'JSONRPC' }],
110
+ provider: { organization: 'Mamuti Aishan (Hasan)', url: 'https://github.com/hmamut39/tewip' },
111
+ version: versionOf(tewipRoot),
112
+ documentationUrl: 'https://github.com/hmamut39/tewip#readme',
113
+ // Both false, and the specification is explicit that an operation whose flag is false must
114
+ // return an error. Declaring a capability that is not implemented is the one thing a card must
115
+ // not do, and streaming a verdict that is computed in one step would buy nothing anyway.
116
+ capabilities: { streaming: false, pushNotifications: false, extendedAgentCard: false },
117
+ defaultInputModes: ['text/plain'],
118
+ defaultOutputModes: ['text/plain', 'text/markdown'],
119
+ skills: ANSWERABLE.map(({ verb: _verb, ...skill }) => skill),
120
+ iconUrl: 'https://raw.githubusercontent.com/hmamut39/tewip/main/assets/logo.svg',
121
+ };
122
+ }
123
+ /** The skill a caller asked for by id, if it is one Tewip answers. */
124
+ export function skillById(id) {
125
+ return ANSWERABLE.find((s) => s.id === id);
126
+ }
@@ -0,0 +1,171 @@
1
+ // Phase 34: serving Tewip to other agents over A2A.
2
+ //
3
+ // This is an HTTP port that runs commands against somebody's repository, so the guardrails are the
4
+ // design rather than a footnote:
5
+ //
6
+ // - **It listens on 127.0.0.1 and nowhere else** unless a person passes --host deliberately, and
7
+ // it says so loudly when they do. A quality tool that quietly opened a port on 0.0.0.0 would be
8
+ // a worse problem than any bug it found.
9
+ // - **Only the read-only skills are reachable.** The allowlist lives in card.ts; `fix`, `write`,
10
+ // `build`, `pursue` and `prove` are not on it, because they change files or run the suite.
11
+ // - **Every request is written to `.tewip/a2a.jsonl`**, the same way `pursue` records what it did,
12
+ // so there is a record of what another agent asked for and what it was told.
13
+ //
14
+ // The shapes below are v1.0's: method `SendMessage` in PascalCase, roles `ROLE_USER`/`ROLE_AGENT`,
15
+ // states like `TASK_STATE_COMPLETED`, and a Part carrying exactly one of text/raw/url/data.
16
+ import { createServer } from 'node:http';
17
+ import { appendFileSync, mkdirSync } from 'node:fs';
18
+ import { dirname, join, resolve } from 'node:path';
19
+ import { randomUUID } from 'node:crypto';
20
+ import { agentCard } from './card.js';
21
+ import { askWhich, chooseSkill, runSkill } from './answer.js';
22
+ const JSONRPC_PATH = '/a2a/v1';
23
+ const CARD_PATH = '/.well-known/agent-card.json';
24
+ /** A2A's own error numbers sit outside the JSON-RPC reserved range; -32601 is "no such method". */
25
+ const METHOD_NOT_FOUND = -32601;
26
+ const INVALID_PARAMS = -32602;
27
+ const UNSUPPORTED = -32004;
28
+ function send(res, status, body) {
29
+ const text = JSON.stringify(body);
30
+ res.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(text) });
31
+ res.end(text);
32
+ }
33
+ const rpcError = (id, code, message) => ({ jsonrpc: '2.0', id: id ?? null, error: { code, message } });
34
+ /** A completed task carrying the verdict as one artifact, which is what A2A expects back. */
35
+ function completedTask(text, failed) {
36
+ return {
37
+ task: {
38
+ id: randomUUID(),
39
+ contextId: randomUUID(),
40
+ status: { state: failed ? 'TASK_STATE_FAILED' : 'TASK_STATE_COMPLETED', timestamp: new Date().toISOString() },
41
+ artifacts: [
42
+ {
43
+ artifactId: randomUUID(),
44
+ name: 'verdict',
45
+ description: "Tewip's answer, in the same words it would print for a person",
46
+ parts: [{ text, mediaType: 'text/markdown' }],
47
+ },
48
+ ],
49
+ },
50
+ };
51
+ }
52
+ /** A task that stops and asks, which is how Tewip declines to guess what was meant. */
53
+ function needsInput(text) {
54
+ return {
55
+ task: {
56
+ id: randomUUID(),
57
+ contextId: randomUUID(),
58
+ status: {
59
+ state: 'TASK_STATE_INPUT_REQUIRED',
60
+ timestamp: new Date().toISOString(),
61
+ message: { messageId: randomUUID(), role: 'ROLE_AGENT', parts: [{ text }] },
62
+ },
63
+ },
64
+ };
65
+ }
66
+ function record(root, entry) {
67
+ try {
68
+ const path = join(root, '.tewip', 'a2a.jsonl');
69
+ mkdirSync(dirname(path), { recursive: true });
70
+ appendFileSync(path, `${JSON.stringify({ at: new Date().toISOString(), ...entry })}\n`, 'utf8');
71
+ }
72
+ catch {
73
+ // A log that cannot be written must not stop an answer being given.
74
+ }
75
+ }
76
+ function readBody(req, limit = 4 * 1024 * 1024) {
77
+ return new Promise((done, fail) => {
78
+ let body = '';
79
+ req.on('data', (chunk) => {
80
+ body += chunk.toString('utf8');
81
+ if (body.length > limit) {
82
+ fail(new Error('request too large'));
83
+ req.destroy();
84
+ }
85
+ });
86
+ req.on('end', () => done(body));
87
+ req.on('error', fail);
88
+ });
89
+ }
90
+ /** Everything the server does with one JSON-RPC call, kept apart from the socket for testing. */
91
+ export function handleRpc(cmd, body) {
92
+ if (typeof body !== 'object' || body === null)
93
+ return { status: 400, payload: rpcError(null, INVALID_PARAMS, 'the body is not a JSON-RPC object') };
94
+ const call = body;
95
+ const id = call.id ?? null;
96
+ const method = typeof call.method === 'string' ? call.method : '';
97
+ // The card says streaming and push notifications are false, and the specification requires an
98
+ // operation whose flag is false to fail rather than silently behave differently.
99
+ if (method === 'SendStreamingMessage' || method === 'SubscribeToTask') {
100
+ return { status: 200, payload: rpcError(id, UNSUPPORTED, 'this agent does not stream; its card declares capabilities.streaming false. Use SendMessage.') };
101
+ }
102
+ if (method.includes('PushNotificationConfig')) {
103
+ return { status: 200, payload: rpcError(id, UNSUPPORTED, 'this agent does not push; its card declares capabilities.pushNotifications false.') };
104
+ }
105
+ if (method !== 'SendMessage') {
106
+ return { status: 200, payload: rpcError(id, METHOD_NOT_FOUND, `this agent answers SendMessage only, and was asked for "${method}"`) };
107
+ }
108
+ const params = (call.params ?? {});
109
+ const message = (params.message ?? null);
110
+ if (message === null || typeof message !== 'object') {
111
+ return { status: 200, payload: rpcError(id, INVALID_PARAMS, 'params.message is required') };
112
+ }
113
+ const choice = chooseSkill(message);
114
+ if (choice.kind === 'ask') {
115
+ record(cmd.root, { method, outcome: 'asked-back', why: choice.why });
116
+ return { status: 200, payload: { jsonrpc: '2.0', id, result: needsInput(askWhich(choice.why)) } };
117
+ }
118
+ if (choice.kind === 'unknown') {
119
+ record(cmd.root, { method, outcome: 'unknown-skill', asked: choice.asked });
120
+ return { status: 200, payload: { jsonrpc: '2.0', id, result: needsInput(askWhich(`"${choice.asked}" is not a skill Tewip has`)) } };
121
+ }
122
+ const answered = runSkill(cmd.root, choice.skill, []);
123
+ record(cmd.root, { method, outcome: 'answered', skill: choice.skill.id, verb: choice.skill.verb, failed: answered.failed });
124
+ return { status: 200, payload: { jsonrpc: '2.0', id, result: completedTask(answered.text, answered.failed) } };
125
+ }
126
+ /** True of an address that only this machine can reach. */
127
+ export const isLoopback = (host) => host === '127.0.0.1' || host === 'localhost' || host === '::1';
128
+ export function serveA2A(cmd) {
129
+ const root = resolve(cmd.root);
130
+ const url = `http://${cmd.host}:${cmd.port}${JSONRPC_PATH}`;
131
+ const server = createServer((req, res) => {
132
+ const path = (req.url ?? '').split('?')[0];
133
+ if (req.method === 'GET' && path === CARD_PATH) {
134
+ send(res, 200, agentCard(url, cmd.tewipRoot));
135
+ return;
136
+ }
137
+ if (req.method !== 'POST' || path !== JSONRPC_PATH) {
138
+ send(res, 404, { error: `nothing here. The card is at ${CARD_PATH} and calls go to ${JSONRPC_PATH}` });
139
+ return;
140
+ }
141
+ readBody(req)
142
+ .then((raw) => {
143
+ let parsed;
144
+ try {
145
+ parsed = JSON.parse(raw);
146
+ }
147
+ catch {
148
+ send(res, 400, rpcError(null, INVALID_PARAMS, 'the body is not JSON'));
149
+ return;
150
+ }
151
+ const { status, payload } = handleRpc({ ...cmd, root }, parsed);
152
+ send(res, status, payload);
153
+ })
154
+ .catch((error) => send(res, 413, rpcError(null, INVALID_PARAMS, error.message)));
155
+ });
156
+ server.listen(cmd.port, cmd.host, () => {
157
+ console.log(`Tewip is answering other agents about ${root}`);
158
+ console.log(` card: http://${cmd.host}:${cmd.port}${CARD_PATH}`);
159
+ console.log(` calls: POST ${url} (JSON-RPC, method "SendMessage")`);
160
+ if (!isLoopback(cmd.host)) {
161
+ console.log('');
162
+ console.log(` WARNING: ${cmd.host} is reachable from outside this machine, and this server has no`);
163
+ console.log(' authentication. Anything that can reach it can ask Tewip to read this repository.');
164
+ console.log(' Only the read-only skills are exposed - nothing here writes a file - but a port');
165
+ console.log(' that reads your source to strangers is still a port that reads your source.');
166
+ }
167
+ console.log('');
168
+ console.log('Every request is recorded in .tewip/a2a.jsonl. Stop with Ctrl+C.');
169
+ });
170
+ return server;
171
+ }
@@ -0,0 +1,190 @@
1
+ const STR = String.raw `['"]([\w.]+)['"]`;
2
+ /** Alembic. */
3
+ /**
4
+ * Sequelize. `queryInterface.removeColumn('Users', 'passwordHash')` names the table and the column
5
+ * in one call, which makes it the simplest of these to read.
6
+ *
7
+ * Written against opencollective-api's real migrations, which also show why the forward path
8
+ * matters more here than anywhere else: a Sequelize migration almost always *adds* in `up` and
9
+ * *removes* in `down`, so a reader that took the whole file would report every harmless migration
10
+ * in the ecosystem as dropping a column.
11
+ */
12
+ const SEQUELIZE = [
13
+ {
14
+ find: new RegExp(String.raw `removeColumn\s*\(\s*${STR}\s*,\s*${STR}`, 'i'),
15
+ risk: 'breaks-old-code',
16
+ what: (m) => `removes ${m[1]}.${m[2]}`,
17
+ instead: 'deploy code that no longer selects or writes the column, then remove it in a later migration',
18
+ table: (m) => m[1],
19
+ column: (m) => m[2],
20
+ },
21
+ {
22
+ find: new RegExp(String.raw `renameColumn\s*\(\s*${STR}\s*,\s*${STR}`, 'i'),
23
+ risk: 'breaks-old-code',
24
+ what: (m) => `renames ${m[1]}.${m[2]}`,
25
+ instead: 'add the new column, write to both, move the readers, then remove the old one',
26
+ table: (m) => m[1],
27
+ column: (m) => m[2],
28
+ },
29
+ {
30
+ find: new RegExp(String.raw `dropTable\s*\(\s*${STR}`, 'i'),
31
+ risk: 'destroys-data',
32
+ what: (m) => `drops the table ${m[1]}`,
33
+ instead: 'stop reading the table, deploy that, then drop it once nothing refers to it',
34
+ table: (m) => m[1],
35
+ },
36
+ {
37
+ // Sequelize has no CONCURRENTLY option, so an index on a populated table takes the lock.
38
+ find: new RegExp(String.raw `addIndex\s*\(\s*${STR}`, 'i'),
39
+ unless: /concurrently\s*:\s*true/i,
40
+ risk: 'locks-writes',
41
+ what: (m) => `builds an index on ${m[1]} while holding a lock that blocks writes to it`,
42
+ instead: 'build it concurrently with a raw query, or accept the lock if the table is small',
43
+ table: (m) => m[1],
44
+ },
45
+ {
46
+ find: new RegExp(String.raw `changeColumn\s*\(\s*${STR}\s*,\s*${STR}`, 'i'),
47
+ risk: 'locks-writes',
48
+ what: (m) => `changes the type of ${m[1]}.${m[2]}, which can rewrite the whole table`,
49
+ instead: 'add a new column, backfill it in batches, and switch the readers over',
50
+ table: (m) => m[1],
51
+ column: (m) => m[2],
52
+ },
53
+ ];
54
+ /**
55
+ * Knex. The awkward one: the table is named by `knex.schema.table('articles', ...)` and the
56
+ * operation happens inside the callback, so the two are matched together across the lines between
57
+ * them. Matterwiki's real migrations are exactly this shape.
58
+ */
59
+ const KNEX = [
60
+ {
61
+ find: new RegExp(String.raw `\.(?:table|alterTable)\s*\(\s*${STR}[\s\S]{0,400}?\.dropColumn\s*\(\s*${STR}`, 'i'),
62
+ risk: 'breaks-old-code',
63
+ what: (m) => `drops ${m[1]}.${m[2]}`,
64
+ instead: 'deploy code that no longer selects or writes the column, then drop it in a later migration',
65
+ table: (m) => m[1],
66
+ column: (m) => m[2],
67
+ },
68
+ {
69
+ find: new RegExp(String.raw `\.(?:table|alterTable)\s*\(\s*${STR}[\s\S]{0,400}?\.renameColumn\s*\(\s*${STR}`, 'i'),
70
+ risk: 'breaks-old-code',
71
+ what: (m) => `renames ${m[1]}.${m[2]}`,
72
+ instead: 'add the new column, write to both, move the readers, then remove the old one',
73
+ table: (m) => m[1],
74
+ column: (m) => m[2],
75
+ },
76
+ {
77
+ find: new RegExp(String.raw `\.dropTable(?:IfExists)?\s*\(\s*${STR}`, 'i'),
78
+ risk: 'destroys-data',
79
+ what: (m) => `drops the table ${m[1]}`,
80
+ instead: 'stop reading the table, deploy that, then drop it once nothing refers to it',
81
+ table: (m) => m[1],
82
+ },
83
+ {
84
+ find: new RegExp(String.raw `\.(?:table|alterTable)\s*\(\s*${STR}[\s\S]{0,400}?\.notNullable\s*\(`, 'i'),
85
+ risk: 'locks-writes',
86
+ what: (m) => `makes a column on ${m[1]} NOT NULL, which scans every row under a lock`,
87
+ instead: 'add a CHECK ... NOT VALID, validate it, then set NOT NULL - or accept the scan if the table is small',
88
+ table: (m) => m[1],
89
+ },
90
+ ];
91
+ /**
92
+ * EF Core. Its MigrationBuilder calls use named arguments over several lines and in either order,
93
+ * so each operation is matched twice - once for `name` before `table`, once for the reverse -
94
+ * rather than assuming the order Coflnet's real migrations happen to use.
95
+ */
96
+ const NAMED = (first, second) => String.raw `(?:${first}:\s*${STR}[\s\S]{0,200}?${second}:\s*${STR}|${second}:\s*${STR}[\s\S]{0,200}?${first}:\s*${STR})`;
97
+ /** Whichever order the arguments came in, the two names are in 1,2 or in 4,3. */
98
+ const pair = (m) => (m[1] !== undefined ? [m[1], m[2]] : [m[4], m[3]]);
99
+ const EFCORE = [
100
+ {
101
+ find: new RegExp(String.raw `migrationBuilder\.DropColumn\s*\(\s*${NAMED('name', 'table')}`, 'i'),
102
+ risk: 'breaks-old-code',
103
+ what: (m) => `drops ${pair(m)[1]}.${pair(m)[0]}`,
104
+ instead: 'deploy code that no longer selects or writes the column, then drop it in a later migration',
105
+ table: (m) => pair(m)[1],
106
+ column: (m) => pair(m)[0],
107
+ },
108
+ {
109
+ find: new RegExp(String.raw `migrationBuilder\.DropTable\s*\(\s*name:\s*${STR}`, 'i'),
110
+ risk: 'destroys-data',
111
+ what: (m) => `drops the table ${m[1]}`,
112
+ instead: 'stop reading the table, deploy that, then drop it once nothing refers to it',
113
+ table: (m) => m[1],
114
+ },
115
+ {
116
+ find: new RegExp(String.raw `migrationBuilder\.RenameColumn\s*\(\s*${NAMED('name', 'table')}`, 'i'),
117
+ risk: 'breaks-old-code',
118
+ what: (m) => `renames ${pair(m)[1]}.${pair(m)[0]}`,
119
+ instead: 'add the new column, write to both, move the readers, then remove the old one',
120
+ table: (m) => pair(m)[1],
121
+ column: (m) => pair(m)[0],
122
+ },
123
+ {
124
+ find: new RegExp(String.raw `migrationBuilder\.CreateIndex\s*\(\s*${NAMED('name', 'table')}`, 'i'),
125
+ risk: 'locks-writes',
126
+ what: (m) => `builds an index on ${pair(m)[1]} while holding a lock that blocks writes to it`,
127
+ instead: 'build it concurrently with migrationBuilder.Sql, or accept the lock if the table is small',
128
+ table: (m) => pair(m)[1],
129
+ },
130
+ {
131
+ find: new RegExp(String.raw `migrationBuilder\.AlterColumn<[^>]*>\s*\(\s*${NAMED('name', 'table')}`, 'i'),
132
+ risk: 'locks-writes',
133
+ what: (m) => `alters ${pair(m)[1]}.${pair(m)[0]}, which can rewrite the whole table`,
134
+ instead: 'add a new column, backfill it in batches, and switch the readers over',
135
+ table: (m) => pair(m)[1],
136
+ column: (m) => pair(m)[0],
137
+ },
138
+ ];
139
+ /**
140
+ * Liquibase. A changelog is XML or YAML rather than code, and the operation is the element name
141
+ * with the table on `tableName`. DEFRA's real changelogs are the shape this was written against.
142
+ */
143
+ const XML_TABLE = String.raw `tableName\s*=\s*["']([\w.]+)["']`;
144
+ const LIQUIBASE = [
145
+ {
146
+ find: new RegExp(String.raw `<dropColumn[^>]*${XML_TABLE}[\s\S]{0,300}?<column[^>]*name\s*=\s*["']([\w.]+)["']`, 'i'),
147
+ risk: 'breaks-old-code',
148
+ what: (m) => `drops ${m[1]}.${m[2]}`,
149
+ instead: 'deploy code that no longer selects or writes the column, then drop it in a later changeSet',
150
+ table: (m) => m[1],
151
+ column: (m) => m[2],
152
+ },
153
+ {
154
+ find: new RegExp(String.raw `<dropColumn[^>]*columnName\s*=\s*["']([\w.]+)["'][^>]*${XML_TABLE}`, 'i'),
155
+ risk: 'breaks-old-code',
156
+ what: (m) => `drops ${m[2]}.${m[1]}`,
157
+ instead: 'deploy code that no longer selects or writes the column, then drop it in a later changeSet',
158
+ table: (m) => m[2],
159
+ column: (m) => m[1],
160
+ },
161
+ {
162
+ find: new RegExp(String.raw `<dropTable[^>]*${XML_TABLE}`, 'i'),
163
+ risk: 'destroys-data',
164
+ what: (m) => `drops the table ${m[1]}`,
165
+ instead: 'stop reading the table, deploy that, then drop it once nothing refers to it',
166
+ table: (m) => m[1],
167
+ },
168
+ {
169
+ find: new RegExp(String.raw `<renameColumn[^>]*${XML_TABLE}`, 'i'),
170
+ risk: 'breaks-old-code',
171
+ what: (m) => `renames a column on ${m[1]}`,
172
+ instead: 'add the new column, write to both, move the readers, then remove the old one',
173
+ table: (m) => m[1],
174
+ },
175
+ {
176
+ find: new RegExp(String.raw `<addNotNullConstraint[^>]*${XML_TABLE}`, 'i'),
177
+ risk: 'locks-writes',
178
+ what: (m) => `makes a column on ${m[1]} NOT NULL, which scans every row under a lock`,
179
+ instead: 'add a CHECK ... NOT VALID, validate it, then set NOT NULL - or accept the scan if the table is small',
180
+ table: (m) => m[1],
181
+ },
182
+ {
183
+ find: new RegExp(String.raw `<createIndex[^>]*${XML_TABLE}`, 'i'),
184
+ risk: 'locks-writes',
185
+ what: (m) => `builds an index on ${m[1]} while holding a lock that blocks writes to it`,
186
+ instead: 'build it concurrently with a <sql> changeSet, or accept the lock if the table is small',
187
+ table: (m) => m[1],
188
+ },
189
+ ];
190
+ export { SEQUELIZE, KNEX, EFCORE, LIQUIBASE };
@@ -0,0 +1,147 @@
1
+ const STR = String.raw `['"]([\w.]+)['"]`;
2
+ /** Django's operations. `model_name` and `name` are the table and the column. */
3
+ const DJANGO = [
4
+ {
5
+ find: new RegExp(String.raw `migrations\.RemoveField\([\s\S]{0,200}?model_name=${STR}[\s\S]{0,200}?name=${STR}`, 'i'),
6
+ risk: 'breaks-old-code',
7
+ what: (m) => `removes the field ${m[2]} from ${m[1]}`,
8
+ instead: 'stop reading and writing the field, deploy that, then remove it in a later migration. The version still running during a rolling deploy will select a column that no longer exists',
9
+ table: (m) => m[1],
10
+ column: (m) => m[2],
11
+ },
12
+ {
13
+ find: new RegExp(String.raw `migrations\.RenameField\([\s\S]{0,200}?model_name=${STR}[\s\S]{0,200}?old_name=${STR}`, 'i'),
14
+ risk: 'breaks-old-code',
15
+ what: (m) => `renames ${m[1]}.${m[2]}`,
16
+ instead: 'add the new field, write to both, move the readers, then remove the old one',
17
+ table: (m) => m[1],
18
+ column: (m) => m[2],
19
+ },
20
+ {
21
+ find: new RegExp(String.raw `migrations\.DeleteModel\([\s\S]{0,120}?${STR}`, 'i'),
22
+ risk: 'destroys-data',
23
+ what: (m) => `deletes the model ${m[1]} and its table`,
24
+ instead: 'stop using it, ship that, confirm nothing reads it, and delete it later',
25
+ table: (m) => m[1],
26
+ },
27
+ {
28
+ find: new RegExp(String.raw `migrations\.AddField\([\s\S]{0,400}?null=False(?![\s\S]{0,400}?default=)`, 'i'),
29
+ risk: 'breaks-old-code',
30
+ what: () => 'adds a field that cannot be null and has no default',
31
+ instead: 'add it with null=True or a default. Without one, Django rewrites the table and every insert from the running version fails',
32
+ },
33
+ {
34
+ find: new RegExp(String.raw `migrations\.AlterField\([\s\S]{0,400}?null=False`, 'i'),
35
+ risk: 'locks-writes',
36
+ what: () => 'makes an existing field NOT NULL, which reads every row under a lock',
37
+ instead: 'backfill first, add a validated constraint, then alter - or use SeparateDatabaseAndState with the safe SQL',
38
+ },
39
+ {
40
+ // A data migration. Django wraps a migration in one transaction, so this holds it open for as long as
41
+ // the work takes - fine for a hundred rows, an outage for a hundred million.
42
+ find: /migrations\.RunPython\(/,
43
+ unless: /atomic\s*=\s*False/,
44
+ risk: 'slow',
45
+ what: () => 'runs application code against the data inside the migration transaction',
46
+ instead: 'for a large table, work in batches with atomic = False on the migration and make it resumable. For a small one this is fine - Tewip cannot see how many rows there are',
47
+ },
48
+ {
49
+ find: /AddIndex\(|AddConstraint\(/i,
50
+ unless: /AddIndexConcurrently|AddConstraintNotValid|atomic\s*=\s*False/,
51
+ risk: 'locks-writes',
52
+ what: () => 'adds an index or constraint, which Django builds with a lock unless told otherwise',
53
+ instead: 'AddIndexConcurrently / AddConstraintNotValid from django.contrib.postgres.operations, with atomic = False on the migration',
54
+ },
55
+ ];
56
+ /** Rails migrations. */
57
+ const RAILS = [
58
+ {
59
+ find: new RegExp(String.raw `remove_column\s*[\s(]\s*:?(\w+)\s*,\s*:?(\w+)`, 'i'),
60
+ risk: 'breaks-old-code',
61
+ what: (m) => `removes ${m[1]}.${m[2]}`,
62
+ instead: "ignore the column in the model (self.ignored_columns), deploy that, then remove it. Rails caches the column list, so the running version keeps selecting it",
63
+ table: (m) => m[1],
64
+ column: (m) => m[2],
65
+ },
66
+ {
67
+ find: new RegExp(String.raw `rename_column\s*[\s(]\s*:?(\w+)\s*,\s*:?(\w+)`, 'i'),
68
+ risk: 'breaks-old-code',
69
+ what: (m) => `renames ${m[1]}.${m[2]}`,
70
+ instead: 'add the new column, write to both, move the readers, then remove the old one',
71
+ table: (m) => m[1],
72
+ column: (m) => m[2],
73
+ },
74
+ {
75
+ find: new RegExp(String.raw `drop_table\s*[\s(]\s*:?(\w+)`, 'i'),
76
+ risk: 'destroys-data',
77
+ what: (m) => `drops the table ${m[1]}`,
78
+ instead: 'stop using it, ship that, then drop it later',
79
+ table: (m) => m[1],
80
+ },
81
+ {
82
+ find: /add_index\s*[\s(]/i,
83
+ unless: /algorithm:\s*:concurrently/,
84
+ risk: 'locks-writes',
85
+ what: () => 'adds an index without algorithm: :concurrently, so writes to the table block while it builds',
86
+ instead: 'add_index ..., algorithm: :concurrently, with disable_ddl_transaction! on the migration',
87
+ },
88
+ {
89
+ find: new RegExp(String.raw `change_column\s*[\s(]\s*:?(\w+)\s*,\s*:?(\w+)`, 'i'),
90
+ risk: 'locks-writes',
91
+ what: (m) => `changes the type of ${m[1]}.${m[2]}, which usually rewrites the table`,
92
+ instead: 'add a new column, backfill in batches, switch the readers, then drop the old one',
93
+ table: (m) => m[1],
94
+ column: (m) => m[2],
95
+ },
96
+ {
97
+ find: new RegExp(String.raw `change_column_null\s*[\s(]\s*:?(\w+)\s*,\s*:?(\w+)\s*,\s*false`, 'i'),
98
+ risk: 'locks-writes',
99
+ what: (m) => `makes ${m[1]}.${m[2]} NOT NULL, which reads every row under a lock`,
100
+ instead: 'add a validated check constraint first, then set the column NOT NULL',
101
+ table: (m) => m[1],
102
+ column: (m) => m[2],
103
+ },
104
+ ];
105
+ const ALEMBIC = [
106
+ {
107
+ find: new RegExp(String.raw `op\.drop_column\(\s*${STR}\s*,\s*${STR}`, 'i'),
108
+ risk: 'breaks-old-code',
109
+ what: (m) => `drops ${m[1]}.${m[2]}`,
110
+ instead: 'stop reading and writing it, deploy that, then drop it in a later revision',
111
+ table: (m) => m[1],
112
+ column: (m) => m[2],
113
+ },
114
+ {
115
+ find: new RegExp(String.raw `op\.drop_table\(\s*${STR}`, 'i'),
116
+ risk: 'destroys-data',
117
+ what: (m) => `drops the table ${m[1]}`,
118
+ instead: 'stop using it, ship that, then drop it later',
119
+ table: (m) => m[1],
120
+ },
121
+ {
122
+ find: new RegExp(String.raw `op\.alter_column\([\s\S]{0,300}?nullable=False`, 'i'),
123
+ risk: 'locks-writes',
124
+ what: () => 'makes a column NOT NULL, which reads every row under a lock',
125
+ instead: 'backfill, add a validated constraint, then alter the column',
126
+ },
127
+ {
128
+ find: /op\.create_index\(/i,
129
+ unless: /postgresql_concurrently\s*=\s*True/,
130
+ risk: 'locks-writes',
131
+ what: () => 'builds an index without postgresql_concurrently, so writes block while it builds',
132
+ instead: 'op.create_index(..., postgresql_concurrently=True) with a non-transactional revision',
133
+ },
134
+ {
135
+ find: new RegExp(String.raw `op\.rename_table\(\s*${STR}`, 'i'),
136
+ risk: 'breaks-old-code',
137
+ what: (m) => `renames the table ${m[1]}`,
138
+ instead: 'move the readers first, or keep a view with the old name',
139
+ table: (m) => m[1],
140
+ },
141
+ ];
142
+ // TypeORM has no operations of its own: unlike Django's `RemoveField` or Rails's `remove_column`,
143
+ // a TypeORM migration contains SQL and nothing else, handed to queryRunner.query(). So its list is
144
+ // empty on purpose and every finding comes from reading that SQL, which is the same reader the
145
+ // `.sql` dialect uses. The exhaustive Record is what made this a compile error rather than a
146
+ // TypeORM migration silently producing no findings at all.
147
+ export { DJANGO, RAILS, ALEMBIC };
@@ -1,3 +1,5 @@
1
+ import { ALEMBIC, DJANGO, RAILS } from './dialect-rules.js';
2
+ import { EFCORE, KNEX, LIQUIBASE, SEQUELIZE } from './dialect-rules-node.js';
1
3
  export function dialectOf(file, text) {
2
4
  const path = file.split('\\').join('/').toLowerCase();
3
5
  if (path.endsWith('.sql'))
@@ -17,158 +19,28 @@ export function dialectOf(file, text) {
17
19
  // at all - and on a Node backend that is most of them.
18
20
  if (/\bMigrationInterface\b|\bqueryRunner\.query\s*\(/.test(text))
19
21
  return 'typeorm';
22
+ if (/\bqueryInterface\.\w+\s*\(/.test(text))
23
+ return 'sequelize';
24
+ if (/\bknex\.schema\b|\bexports\.(?:up|down)\s*=\s*(?:async\s*)?function\s*\(\s*knex\b/.test(text))
25
+ return 'knex';
26
+ if (/\bmigrationBuilder\.\w+|\b:\s*Migration\b[\s\S]*\bMigrationBuilder\b/.test(text))
27
+ return 'efcore';
28
+ if (/<databaseChangeLog\b|\bdatabaseChangeLog\s*:/.test(text))
29
+ return 'liquibase';
20
30
  if (/(^|\/)(migrations?|db\/migrate)\//.test(path))
21
31
  return 'unknown';
22
32
  return 'unknown';
23
33
  }
24
- const STR = String.raw `['"]([\w.]+)['"]`;
25
- /** Django's operations. `model_name` and `name` are the table and the column. */
26
- const DJANGO = [
27
- {
28
- find: new RegExp(String.raw `migrations\.RemoveField\([\s\S]{0,200}?model_name=${STR}[\s\S]{0,200}?name=${STR}`, 'i'),
29
- risk: 'breaks-old-code',
30
- what: (m) => `removes the field ${m[2]} from ${m[1]}`,
31
- instead: 'stop reading and writing the field, deploy that, then remove it in a later migration. The version still running during a rolling deploy will select a column that no longer exists',
32
- table: (m) => m[1],
33
- column: (m) => m[2],
34
- },
35
- {
36
- find: new RegExp(String.raw `migrations\.RenameField\([\s\S]{0,200}?model_name=${STR}[\s\S]{0,200}?old_name=${STR}`, 'i'),
37
- risk: 'breaks-old-code',
38
- what: (m) => `renames ${m[1]}.${m[2]}`,
39
- instead: 'add the new field, write to both, move the readers, then remove the old one',
40
- table: (m) => m[1],
41
- column: (m) => m[2],
42
- },
43
- {
44
- find: new RegExp(String.raw `migrations\.DeleteModel\([\s\S]{0,120}?${STR}`, 'i'),
45
- risk: 'destroys-data',
46
- what: (m) => `deletes the model ${m[1]} and its table`,
47
- instead: 'stop using it, ship that, confirm nothing reads it, and delete it later',
48
- table: (m) => m[1],
49
- },
50
- {
51
- find: new RegExp(String.raw `migrations\.AddField\([\s\S]{0,400}?null=False(?![\s\S]{0,400}?default=)`, 'i'),
52
- risk: 'breaks-old-code',
53
- what: () => 'adds a field that cannot be null and has no default',
54
- instead: 'add it with null=True or a default. Without one, Django rewrites the table and every insert from the running version fails',
55
- },
56
- {
57
- find: new RegExp(String.raw `migrations\.AlterField\([\s\S]{0,400}?null=False`, 'i'),
58
- risk: 'locks-writes',
59
- what: () => 'makes an existing field NOT NULL, which reads every row under a lock',
60
- instead: 'backfill first, add a validated constraint, then alter - or use SeparateDatabaseAndState with the safe SQL',
61
- },
62
- {
63
- // A data migration. Django wraps a migration in one transaction, so this holds it open for as long as
64
- // the work takes - fine for a hundred rows, an outage for a hundred million.
65
- find: /migrations\.RunPython\(/,
66
- unless: /atomic\s*=\s*False/,
67
- risk: 'slow',
68
- what: () => 'runs application code against the data inside the migration transaction',
69
- instead: 'for a large table, work in batches with atomic = False on the migration and make it resumable. For a small one this is fine - Tewip cannot see how many rows there are',
70
- },
71
- {
72
- find: /AddIndex\(|AddConstraint\(/i,
73
- unless: /AddIndexConcurrently|AddConstraintNotValid|atomic\s*=\s*False/,
74
- risk: 'locks-writes',
75
- what: () => 'adds an index or constraint, which Django builds with a lock unless told otherwise',
76
- instead: 'AddIndexConcurrently / AddConstraintNotValid from django.contrib.postgres.operations, with atomic = False on the migration',
77
- },
78
- ];
79
- /** Rails migrations. */
80
- const RAILS = [
81
- {
82
- find: new RegExp(String.raw `remove_column\s*[\s(]\s*:?(\w+)\s*,\s*:?(\w+)`, 'i'),
83
- risk: 'breaks-old-code',
84
- what: (m) => `removes ${m[1]}.${m[2]}`,
85
- instead: "ignore the column in the model (self.ignored_columns), deploy that, then remove it. Rails caches the column list, so the running version keeps selecting it",
86
- table: (m) => m[1],
87
- column: (m) => m[2],
88
- },
89
- {
90
- find: new RegExp(String.raw `rename_column\s*[\s(]\s*:?(\w+)\s*,\s*:?(\w+)`, 'i'),
91
- risk: 'breaks-old-code',
92
- what: (m) => `renames ${m[1]}.${m[2]}`,
93
- instead: 'add the new column, write to both, move the readers, then remove the old one',
94
- table: (m) => m[1],
95
- column: (m) => m[2],
96
- },
97
- {
98
- find: new RegExp(String.raw `drop_table\s*[\s(]\s*:?(\w+)`, 'i'),
99
- risk: 'destroys-data',
100
- what: (m) => `drops the table ${m[1]}`,
101
- instead: 'stop using it, ship that, then drop it later',
102
- table: (m) => m[1],
103
- },
104
- {
105
- find: /add_index\s*[\s(]/i,
106
- unless: /algorithm:\s*:concurrently/,
107
- risk: 'locks-writes',
108
- what: () => 'adds an index without algorithm: :concurrently, so writes to the table block while it builds',
109
- instead: 'add_index ..., algorithm: :concurrently, with disable_ddl_transaction! on the migration',
110
- },
111
- {
112
- find: new RegExp(String.raw `change_column\s*[\s(]\s*:?(\w+)\s*,\s*:?(\w+)`, 'i'),
113
- risk: 'locks-writes',
114
- what: (m) => `changes the type of ${m[1]}.${m[2]}, which usually rewrites the table`,
115
- instead: 'add a new column, backfill in batches, switch the readers, then drop the old one',
116
- table: (m) => m[1],
117
- column: (m) => m[2],
118
- },
119
- {
120
- find: new RegExp(String.raw `change_column_null\s*[\s(]\s*:?(\w+)\s*,\s*:?(\w+)\s*,\s*false`, 'i'),
121
- risk: 'locks-writes',
122
- what: (m) => `makes ${m[1]}.${m[2]} NOT NULL, which reads every row under a lock`,
123
- instead: 'add a validated check constraint first, then set the column NOT NULL',
124
- table: (m) => m[1],
125
- column: (m) => m[2],
126
- },
127
- ];
128
- /** Alembic. */
129
- const ALEMBIC = [
130
- {
131
- find: new RegExp(String.raw `op\.drop_column\(\s*${STR}\s*,\s*${STR}`, 'i'),
132
- risk: 'breaks-old-code',
133
- what: (m) => `drops ${m[1]}.${m[2]}`,
134
- instead: 'stop reading and writing it, deploy that, then drop it in a later revision',
135
- table: (m) => m[1],
136
- column: (m) => m[2],
137
- },
138
- {
139
- find: new RegExp(String.raw `op\.drop_table\(\s*${STR}`, 'i'),
140
- risk: 'destroys-data',
141
- what: (m) => `drops the table ${m[1]}`,
142
- instead: 'stop using it, ship that, then drop it later',
143
- table: (m) => m[1],
144
- },
145
- {
146
- find: new RegExp(String.raw `op\.alter_column\([\s\S]{0,300}?nullable=False`, 'i'),
147
- risk: 'locks-writes',
148
- what: () => 'makes a column NOT NULL, which reads every row under a lock',
149
- instead: 'backfill, add a validated constraint, then alter the column',
150
- },
151
- {
152
- find: /op\.create_index\(/i,
153
- unless: /postgresql_concurrently\s*=\s*True/,
154
- risk: 'locks-writes',
155
- what: () => 'builds an index without postgresql_concurrently, so writes block while it builds',
156
- instead: 'op.create_index(..., postgresql_concurrently=True) with a non-transactional revision',
157
- },
158
- {
159
- find: new RegExp(String.raw `op\.rename_table\(\s*${STR}`, 'i'),
160
- risk: 'breaks-old-code',
161
- what: (m) => `renames the table ${m[1]}`,
162
- instead: 'move the readers first, or keep a view with the old name',
163
- table: (m) => m[1],
164
- },
165
- ];
166
- // TypeORM has no operations of its own: unlike Django's `RemoveField` or Rails's `remove_column`,
167
- // a TypeORM migration contains SQL and nothing else, handed to queryRunner.query(). So its list is
168
- // empty on purpose and every finding comes from reading that SQL, which is the same reader the
169
- // `.sql` dialect uses. The exhaustive Record is what made this a compile error rather than a
170
- // TypeORM migration silently producing no findings at all.
171
- const BY_DIALECT = { django: DJANGO, rails: RAILS, alembic: ALEMBIC, typeorm: [] };
34
+ const BY_DIALECT = {
35
+ django: DJANGO,
36
+ rails: RAILS,
37
+ alembic: ALEMBIC,
38
+ typeorm: [],
39
+ sequelize: SEQUELIZE,
40
+ knex: KNEX,
41
+ efcore: EFCORE,
42
+ liquibase: LIQUIBASE,
43
+ };
172
44
  /** Findings from a framework migration, with the line each one sits on. */
173
45
  export function readFramework(dialect, text) {
174
46
  const lines = text.split(String.fromCharCode(10));
@@ -230,10 +102,24 @@ export function forwardOnly(dialect, text) {
230
102
  (dialect === 'sql' && /^\s*--\s*(?:\+migrate\s+Down|\+goose\s+Down|\/\/@UNDO|Down\b)/i.test(line)) ||
231
103
  // TypeORM's rollback is `public async down(queryRunner: QueryRunner)`. Reading it as part of
232
104
  // the change would report a migration that adds a column as one that drops it.
233
- (dialect === 'typeorm' && /^\s*(?:public\s+)?async\s+down\s*\(/.test(line));
105
+ (dialect === 'typeorm' && /^\s*(?:public\s+)?async\s+down\s*\(/.test(line)) ||
106
+ // These three matter more than the rest. A Sequelize or Knex migration almost always *adds*
107
+ // in up and *removes* in down, and an EF Core Down() is the mirror image of its Up(), so a
108
+ // reader that took the whole file would report nearly every harmless migration in those
109
+ // ecosystems as dropping a column. Verified on real migrations from opencollective-api,
110
+ // Matterwiki and Coflnet, all three of which have their only destructive call in down.
111
+ (dialect === 'sequelize' && /^\s*down\s*:/.test(line)) ||
112
+ (dialect === 'knex' && /^\s*(?:exports\.down|module\.exports\.down)\s*=/.test(line)) ||
113
+ (dialect === 'efcore' && /^\s*protected\s+override\s+void\s+Down\s*\(/.test(line)) ||
114
+ // Liquibase puts the reverse inside the change itself rather than in a second method.
115
+ (dialect === 'liquibase' && /^\s*<rollback\b/.test(line));
234
116
  if (down) {
235
117
  // To the end of the block: the next line at the same indentation or less that starts something new.
236
- blankFrom(i, (l, indent) => l.trim() !== '' && indentOf(l) <= indent && !/^\s*(end|\})\s*$/.test(l), indentOf(line));
118
+ // A lone brace is punctuation, not the start of something new. C# puts the opening `{` of a
119
+ // method on its own line at the same indent as the signature, so without `{` here the block
120
+ // ended one line after it began and the whole of EF Core's Down() was read as part of the
121
+ // change - which reported Coflnet's real migration as dropping three columns it adds.
122
+ blankFrom(i, (l, indent) => l.trim() !== '' && indentOf(l) <= indent && !/^\s*(end|\}|\{)\s*$/.test(l), indentOf(line));
237
123
  continue;
238
124
  }
239
125
  // Rails's `direction.down { ... }` is usually one line.
@@ -70,7 +70,7 @@ export function readMigration(root, file) {
70
70
  file,
71
71
  dialect,
72
72
  findings: [],
73
- unread: 'Tewip does not recognise how this migration is written, so it says nothing about it rather than passing it. It reads SQL, Django, Rails, Alembic and TypeORM migrations',
73
+ unread: 'Tewip does not recognise how this migration is written, so it says nothing about it rather than passing it. It reads SQL, Django, Rails, Alembic, TypeORM, Sequelize, Knex, EF Core and Liquibase migrations',
74
74
  irreversible: false,
75
75
  };
76
76
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tewip-ai",
3
- "version": "0.40.0",
3
+ "version": "0.42.0",
4
4
  "mcpName": "io.github.hmamut39/tewip",
5
5
  "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.",
6
6
  "keywords": [