tewip-ai 0.69.9 → 0.70.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.
@@ -48,7 +48,16 @@ function section(title, findings, lines) {
48
48
  }
49
49
  }
50
50
  export function renderAccess(result, scanned) {
51
- const { barriers, questions, accepted, filesRead, browser } = result;
51
+ // A missing list means an empty one. `accepted` and the browser run's own lists were read
52
+ // straight off the object, so a caller that left one out got
53
+ // `TypeError: Cannot read properties of undefined (reading 'length')` from the renderer rather
54
+ // than a report - and this is an exported function the MCP tool also calls. It happened twice in
55
+ // one hour during Phase 68, and the test that pins it was written by `tewip incident` from the
56
+ // real stack trace of the second time.
57
+ const { barriers = [], questions = [], accepted = [], filesRead = 0 } = result;
58
+ const browser = result.browser
59
+ ? { ...result.browser, barriers: result.browser.barriers ?? [], questions: result.browser.questions ?? [], advice: result.browser.advice ?? [], superseded: result.browser.superseded ?? [] }
60
+ : result.browser;
52
61
  const lines = ['# Tewip accessibility check', ''];
53
62
  lines.push(`Read ${filesRead} markup ${filesRead === 1 ? 'file' : 'files'} under \`${scanned}\`, without rendering any of them.`, '');
54
63
  if (browser) {
@@ -14,7 +14,9 @@ export function verifyAttestation(file, keyFile) {
14
14
  const bundle = JSON.parse(readFileSync(resolve(file), 'utf8'));
15
15
  const publicKey = keyFile ? readFileSync(resolve(keyFile), 'utf8') : undefined;
16
16
  const checked = check(bundle, publicKey);
17
- const ok = checked.contentMatches && checked.signature !== 'invalid';
17
+ // 'stripped' counts as a failure: the caller supplied a key, so it asked who made this, and a
18
+ // bundle with no signature cannot answer. See `check` in seal.ts for the attack that exposed it.
19
+ const ok = checked.contentMatches && checked.signature !== 'invalid' && checked.signature !== 'stripped';
18
20
  const report = [
19
21
  `# ${ok ? 'Checked' : 'Do not trust this bundle'}: ${file.split(/[\/]/).pop()}`,
20
22
  '',
@@ -20,18 +20,24 @@ import { createHash, createPrivateKey, createPublicKey, sign as cryptoSign, veri
20
20
  * different bundle.
21
21
  */
22
22
  export function canonical(bundle) {
23
- const ordered = (value) => {
23
+ // The seal is left out of its own digest, which it has to be - but only the **top-level** one.
24
+ // The filter used to apply at every depth, so any field named `seal` anywhere inside the bundle
25
+ // was invisible to the digest: content could be added under `claims[0].seal` without changing the
26
+ // digest or invalidating the signature over it. Nothing reads such a field today, which is why
27
+ // this is small rather than serious; the claim being made is that the digest covers the content,
28
+ // and a digest with a hole in it is not that claim.
29
+ const ordered = (value, top = false) => {
24
30
  if (Array.isArray(value))
25
- return value.map(ordered);
31
+ return value.map((v) => ordered(v));
26
32
  if (value && typeof value === 'object') {
27
33
  return Object.fromEntries(Object.entries(value)
28
- .filter(([k]) => k !== 'seal')
34
+ .filter(([k]) => !(top && k === 'seal'))
29
35
  .sort(([a], [b]) => a.localeCompare(b))
30
36
  .map(([k, v]) => [k, ordered(v)]));
31
37
  }
32
38
  return value;
33
39
  };
34
- return JSON.stringify(ordered(bundle));
40
+ return JSON.stringify(ordered(bundle, true));
35
41
  }
36
42
  export const digestOf = (bundle) => createHash('sha256').update(canonical(bundle)).digest('hex');
37
43
  /**
@@ -65,10 +71,27 @@ export function check(bundle, publicKeyPem) {
65
71
  return { contentMatches: false, signature: bundle.seal?.signature ? 'invalid' : 'absent', says: 'the content does not match the digest recorded inside it: it has been changed since it was written' };
66
72
  }
67
73
  if (!bundle.seal?.signature) {
74
+ // A key was given, so authorship is what was asked about - and there is nothing here to answer
75
+ // with. This used to return the same pass as an unsigned bundle checked without a key, so:
76
+ // change a fact, erase the "what was not checked" list, delete the signature, recompute the
77
+ // digest, and `attest --verify bundle.json --key release.pem` printed "Content matches its
78
+ // digest: yes" and **exited 0**. Any gate scripted on it passed. Removing a signature is the
79
+ // oldest attack on one, and "there is nothing to check" is a failure to establish what was
80
+ // asked, not a pass.
81
+ if (publicKeyPem?.trim()) {
82
+ return {
83
+ contentMatches: true,
84
+ signature: 'stripped',
85
+ says: 'a public key was given but this bundle carries no signature, so who produced it cannot be established. ' +
86
+ 'The content does match the digest recorded inside it, and that is worth nothing here: anyone who edits a bundle can recompute its digest, which is exactly what removing the signature would look like',
87
+ };
88
+ }
68
89
  return {
69
90
  contentMatches: true,
70
91
  signature: 'absent',
71
- says: 'the content matches its own digest, and nothing more: no signature was made, so this shows the bundle is undamaged, not that nobody edited it - an editor can recompute a digest',
92
+ // "no signature was made" asserted something about the bundle's history that cannot be known
93
+ // from the bundle: it may have been removed. What is observable is that it carries none.
94
+ says: 'the content matches its own digest, and nothing more: this bundle carries no signature, so this shows it is undamaged, not that nobody edited it - an editor can recompute a digest',
72
95
  };
73
96
  }
74
97
  if (!publicKeyPem?.trim()) {
@@ -11,7 +11,7 @@
11
11
  // never ran is reported as exactly that and the file is removed, because a test wrongly labelled
12
12
  // "reproduces the outage" is worse than no test: the next person will trust it.
13
13
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
14
- import { dirname, join, resolve } from 'node:path';
14
+ import { dirname, join, relative, resolve } from 'node:path';
15
15
  import { noModelHelp } from '../llm/providers.js';
16
16
  import { writerFromEnv } from '../quality/model.js';
17
17
  import { usageLine } from '../llm/usage.js';
@@ -54,6 +54,16 @@ function asScenario(incident) {
54
54
  expected: `it fails with ${[incident.errorType, incident.errorMessage].filter(Boolean).join(': ') || incident.title}. Write the test asserting the behaviour that SHOULD happen, so it is red now and green once the bug is fixed.`,
55
55
  };
56
56
  }
57
+ /**
58
+ * How to import a source file from the folder the test will be written in.
59
+ *
60
+ * Always relative and always with a `./` or `../` in front, because a bare `access/render.ts` is a
61
+ * package specifier to Node, not a path.
62
+ */
63
+ export function importPathFor(root, testDir, file) {
64
+ const path = relative(resolve(testDir), resolve(root, file)).split('\\').join('/');
65
+ return path.startsWith('.') ? path : `./${path}`;
66
+ }
57
67
  export async function runIncident(cmd) {
58
68
  const incident = readIncident(readFileSync(resolve(cmd.file), 'utf8'));
59
69
  const framework = chooseFramework(detectFrameworks(cmd.root), cmd.framework, false);
@@ -69,7 +79,11 @@ export async function runIncident(cmd) {
69
79
  const fileName = framework.fileFor(`incident-${Date.now().toString(36)}`);
70
80
  const testFile = join(framework.testDir, fileName);
71
81
  mkdirSync(dirname(testFile), { recursive: true });
72
- const draft = await draftTest(model, { framework, fileName, pages: [], feature: `A production incident: ${incident.title}`, code }, asScenario(incident));
82
+ // Each source file with the path to import it by from the test's own folder. Without this the
83
+ // model only had project-relative paths and every import it wrote was wrong for any framework
84
+ // whose test folder is not the project root.
85
+ const withImports = code.map((c) => ({ ...c, importPath: importPathFor(cmd.root, framework.testDir, c.file) }));
86
+ const draft = await draftTest(model, { framework, fileName, pages: [], feature: `A production incident: ${incident.title}`, code: withImports, testDir: relative(cmd.root, framework.testDir).split('\\').join('/') || '.' }, asScenario(incident));
73
87
  writeFileSync(testFile, draft.code, 'utf8');
74
88
  console.error('Wrote the test; running it now, because a test nobody ran proves nothing…');
75
89
  const run = runTest(framework, testFile);
@@ -1,18 +1,48 @@
1
1
  const LIBRARY = /[\\/](?:node_modules|site-packages|dist-packages|vendor|\.venv|venv|target[\\/]dependency|gems)[\\/]/;
2
2
  const RUNTIME = /^(?:node:|internal[\\/]|java\.|javax\.|sun\.|System\.|Microsoft\.)/;
3
3
  const ours = (file) => Boolean(file) && !LIBRARY.test(file) && !RUNTIME.test(file);
4
- const frame = (file, line, fn) => ({
5
- file: String(file ?? '').replace(/\\/g, '/'),
6
- line: Number(line) || 0,
7
- fn: String(fn ?? ''),
8
- ours: ours(String(file ?? '')),
9
- });
4
+ /**
5
+ * A `file://` URL back into the path it names.
6
+ *
7
+ * Node has printed ESM stack frames as URLs since v12, and ESM is the default for every project with
8
+ * `"type": "module"` - which this one is. `file:///app/src/pay.ts` and `file:///C:/app/src/pay.ts`
9
+ * are what a real trace contains, and a path is what every later step needs.
10
+ */
11
+ function asPath(file) {
12
+ const text = String(file ?? '').replace(/\\/g, '/');
13
+ if (!/^file:\/\//i.test(text))
14
+ return text;
15
+ const withoutScheme = text.replace(/^file:\/\/\/?/i, '');
16
+ // A Windows drive keeps its letter; everything else is rooted.
17
+ const path = /^[a-z]:/i.test(withoutScheme) ? withoutScheme : `/${withoutScheme}`;
18
+ try {
19
+ return decodeURIComponent(path);
20
+ }
21
+ catch {
22
+ return path; // a malformed escape is not a reason to lose the frame
23
+ }
24
+ }
25
+ const frame = (file, line, fn) => {
26
+ const path = asPath(file);
27
+ return { file: path, line: Number(line) || 0, fn: String(fn ?? ''), ours: ours(path) };
28
+ };
10
29
  /** A stack trace as every language prints it, which is the fallback when there is no tool. */
11
30
  export function framesFromStack(stack) {
12
31
  const out = [];
13
32
  for (const line of stack.split('\n')) {
14
- // JavaScript: "at handler (/app/src/pay.ts:42:9)" or "at /app/src/pay.ts:42:9"
15
- const js = /^\s*at\s+(?:(?<fn>[^(]+?)\s+\()?(?<file>[^():]+?):(?<line>\d+):\d+\)?\s*$/.exec(line);
33
+ // JavaScript: "at handler (/app/src/pay.ts:42:9)" or "at /app/src/pay.ts:42:9", and - what a
34
+ // modern Node actually prints - "at handler (file:///C:/app/src/pay.ts:42:9)".
35
+ //
36
+ // The file group was `[^():]+?`: no colons allowed. A `file://` URL has one in the scheme and a
37
+ // Windows path has another in the drive letter, so **not one ESM frame could be read** - on any
38
+ // platform, in any project with `"type": "module"`, which is most of them and is this one. The
39
+ // command's whole premise is writing a test against the code the trace names, and it could not
40
+ // name the code. Found on a real trace from this repository: `incident` reported "it names no
41
+ // file inside this project" about `engine/access/render.ts:129`, which is in this project.
42
+ //
43
+ // The group now takes anything and the tail anchors it: `:digits:digits` at the end of the line
44
+ // is what makes a JavaScript frame, and a JVM frame has only one number so it still falls through.
45
+ const js = /^\s*at\s+(?:(?<fn>[^(]+?)\s+\()?(?<file>.+?):(?<line>\d+):\d+\)?\s*$/.exec(line);
16
46
  if (js?.groups) {
17
47
  out.push(frame(js.groups.file, js.groups.line, (js.groups.fn ?? '').trim()));
18
48
  continue;
@@ -14,7 +14,8 @@ const RULES = `Rules:
14
14
  - Wait for conditions, never for fixed times.
15
15
  - Do not hard-code values that change as the app's content grows (counts, totals, dates, ids) unless the scenario is about that exact value: check their shape instead (a number of books, a date), so the test does not break the day a record is added.
16
16
  - One test file, complete, runnable as is, no placeholders. It must actually run here: Tewip runs it against the app now, and a skipped test proves nothing. A guard like the example's (skip where the app lacks something) is fine, as long as it does not skip here.
17
- - mutation.find must be an exact substring of your code that appears once, inside the main assertion (the value or condition it expects); mutation.replace is what to put there instead so that the test must fail if it is a real check. Keep the code valid: change an expected value, not the syntax.`;
17
+ - mutation.find must be an exact substring of your code that appears once, inside the main assertion (the value or condition it expects); mutation.replace is what to put there instead so that the test must fail if it is a real check. Keep the code valid: change an expected value, not the syntax.
18
+ - To import something from codeUnderTest, use that entry's importPath exactly as given. It is already relative to the folder the test will be written in (testWillBeWrittenIn); its \`file\` is relative to the project root and is there to tell you where the code lives, not how to import it.`;
18
19
  const DRAFT_SYSTEM = `You are Tewip's test writer, a senior SDET. Write one automated test for the scenario given.\n\n${RULES}`;
19
20
  const REPAIR_SYSTEM = `You are Tewip's test writer, a senior SDET. Your test failed when run against the working app. Read the real error and decide:
20
21
  - fault "test": the test is wrong (a locator, a wait, a wrong assumption about the page). Return the corrected full test.
@@ -44,6 +45,9 @@ function contextFor(ctx, scenario) {
44
45
  exampleTest: example,
45
46
  pages: ctx.pages.map((p) => ({ readAt: p.url, title: p.title, accessibilitySnapshot: p.aria })),
46
47
  codeUnderTest: ctx.code?.length ? ctx.code : undefined,
48
+ // Where the test file itself will sit, so a relative import can be written correctly rather
49
+ // than guessed from a project-relative path.
50
+ testWillBeWrittenIn: ctx.testDir || undefined,
47
51
  houseRules: ctx.houseRules || undefined,
48
52
  };
49
53
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tewip-ai",
3
- "version": "0.69.9",
3
+ "version": "0.70.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": [