tewip-ai 0.6.0 → 0.7.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
@@ -160,9 +160,31 @@ pixels, colours or spacing, because a design tool and a browser do not measure t
160
160
  No model is called, so it costs nothing and answers the same way every time.
161
161
 
162
162
  Measured on four real design/page pairs (a public repository's Figma exports and the pages built
163
- from them): 38 checks, **0 differences reported on pages that match their design**, and **8 of 8
164
- injected drifts caught** - a renamed button, a dropped sentence, a region that stopped being marked
165
- up. No designer has judged the output yet, so the roadmap's own metric is still open.
163
+ from them): 38 checks, **0 differences reported on pages that match their design**, **11 of 11 real
164
+ drifts caught** (a renamed control, a dropped sentence, a word swapped inside a sentence, a region
165
+ that lost its markup) and **5 of 5 harmless changes ignored** (capitals, extra spaces, a button
166
+ implemented as a link, reordered sections). No designer has judged the output yet, so the roadmap's
167
+ own metric is still open.
168
+
169
+ ## Security: which findings actually matter (Phase 9, new)
170
+
171
+ ```bash
172
+ tewip secure # secrets + npm audit / pip-audit / osv-scanner, triaged
173
+ tewip secure --secrets-only # a fast check before you commit
174
+ tewip secure --gate # fails a build only on what needs a person now
175
+ ```
176
+
177
+ Scanners are good at finding things and bad at saying which of them matter. Tewip runs the ones your
178
+ project already has and sorts every finding into **act on this now**, **real but not reachable**
179
+ (a dev dependency, a package nothing imports, a file git does not track), **noise** (a placeholder
180
+ from a README), or **accepted** (a person already judged it, in a file only people write). Each
181
+ verdict carries the evidence. No model is called, so it costs nothing, and **a credential is
182
+ located, never printed**.
183
+
184
+ Measured on the gitleaks project's own labelled samples, for the credential kinds Tewip covers:
185
+ **50 of 54 real credentials found**. Four defects that corpus exposed are fixed; the four still
186
+ missed are things that are not credentials (an unsigned token, a Stripe *test* key). It never claims
187
+ a project is secure - it prints what it checked and what it did not.
166
188
 
167
189
  ## Epics, stories and acceptance criteria (Phase 14, new)
168
190
 
package/TEAM-GUIDE.md CHANGED
@@ -121,6 +121,7 @@ collects environment failures in one issue, and (optionally) opens a fix pull re
121
121
  | `tewip write` | Turns the plan's scenarios into real tests in your own framework, and keeps only the ones that pass repeatedly **and** fail when their check is broken on purpose | When you know what to test but not how to write it |
122
122
  | `tewip plan --issue PROJ-123` | Plans from the Jira ticket itself: its description and acceptance criteria | "Here's the story, what should we test?" |
123
123
  | `tewip jira plan.json --project PROJ` | Files the plan's scenarios as test-case tickets, linked to the story | Handing work to the team |
124
+ | `tewip secure` | Runs your dependency and secret scanners, then says which findings need someone now and which are noise or unreachable | Before a release, or in CI |
124
125
  | `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 |
125
126
  | `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 |
126
127
  | `tewip watch --cmd "npm test"` | Runs your tests when you save a file and explains each failure straight away | While you are writing code |
@@ -136,6 +137,8 @@ Notes that save you time:
136
137
  - `write` never touches your application code. It only adds new test files, and you review and
137
138
  commit them yourself.
138
139
  - Everything it writes locally goes to the `.tewip` folder in your project.
140
+ - Jira works with **Cloud, Server and Data Center**. To read tickets from a tracker anyone can read
141
+ (many open-source ones), `TEWIP_JIRA_URL` alone is enough - no account. Writing needs a token.
139
142
  - `tewip jira` shows what it *would* file and creates nothing until you add `--create`. It never
140
143
  files the same scenario twice, and never moves, assigns or closes a ticket.
141
144
  - `tewip report` posts to Slack or Teams only when a failure needs a person (`TEWIP_SLACK_WEBHOOK`,
@@ -186,7 +189,10 @@ Notes that save you time:
186
189
  design says and what the page says, side by side, so a renamed button or a dropped sentence is
187
190
  found before review.
188
191
 
189
- **Security** — planned, not built yet (`tewip secure`, Phase 9 in ROADMAP.md).
192
+ **Security**
193
+ - `tewip secure` triages what the scanners find: what is committed and reachable, what is only in a
194
+ dev dependency, what is a placeholder in a README. A credential is never printed, only located,
195
+ and `.tewip/security-accepted.txt` records what your team has already judged.
190
196
 
191
197
  Managers, architects and leads do not need to install anything: those views are produced by the CI
192
198
  job and kept in a GitHub issue, or can be asked for through an AI assistant.
package/dist/cli/tewip.js CHANGED
@@ -38,6 +38,11 @@ Usage:
38
38
  File the plan's scenarios as test-case issues, linked to the
39
39
  story. Shows what it would create; --create actually files
40
40
  them, and the same scenario is never filed twice.
41
+ tewip secure [--secrets-only] [--gate] [--out security.md]
42
+ Runs the scanners this project already has (npm audit,
43
+ pip-audit, osv-scanner) plus Tewip's own secret scan, then says
44
+ which findings need someone now and which are noise or not
45
+ reachable. No model, no cost, and it never prints a credential.
41
46
  tewip spec "<feature>" [--diff main...HEAD] [--url <page> ...] [--project PROJ] [--create]
42
47
  An epic, its user stories and their acceptance criteria, from
43
48
  this project's code, tests and failure history. Anything it
@@ -151,6 +156,19 @@ async function main() {
151
156
  });
152
157
  return;
153
158
  }
159
+ case 'secure': {
160
+ const { runSecure } = await import('../engine/secure/secure-command.js');
161
+ const items = await runSecure({
162
+ root: process.cwd(),
163
+ out: flag(args, 'out'),
164
+ secretsOnly: args.includes('--secrets-only'),
165
+ gate: args.includes('--gate'),
166
+ });
167
+ // The gate fails a build only for what Tewip decided a person must act on now.
168
+ if (args.includes('--gate') && items.some((i) => i.verdict === 'real-risk'))
169
+ process.exitCode = 1;
170
+ return;
171
+ }
154
172
  case 'spec': {
155
173
  const feature = args.filter((a, i) => !a.startsWith('--') && !['--diff', '--url', '--out', '--project'].includes(args[i - 1] ?? '')).join(' ').trim();
156
174
  if (!feature)
@@ -0,0 +1,123 @@
1
+ // Phase 9: known-vulnerable dependencies. Tewip runs no scanner of its own here - the ecosystems
2
+ // already have good ones, and their databases are updated daily by people whose job that is. It
3
+ // runs whichever is available (npm, pip, or OSV for everything else), reads their output, and then
4
+ // does the part they do not: say which findings actually matter in this project.
5
+ import { existsSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { spawnSync } from 'node:child_process';
8
+ const run = (command, cwd, timeoutMs = 5 * 60_000) => {
9
+ const r = spawnSync(command, { cwd, shell: true, encoding: 'utf8', timeout: timeoutMs, maxBuffer: 40 * 1024 * 1024 });
10
+ return { code: r.status ?? 1, out: `${r.stdout ?? ''}`, err: `${r.stderr ?? ''}` };
11
+ };
12
+ const has = (command, cwd) => run(`${command} --version`, cwd, 30_000).code === 0;
13
+ const SEVERITY_ORDER = ['critical', 'high', 'moderate', 'medium', 'low', 'info'];
14
+ export const bySeverity = (a, b) => SEVERITY_ORDER.indexOf(a.severity.toLowerCase()) - SEVERITY_ORDER.indexOf(b.severity.toLowerCase());
15
+ /** `npm audit --json`, which needs no install and no account: every Node project already has it. */
16
+ export function npmAudit(root) {
17
+ if (!existsSync(join(root, 'package.json')))
18
+ return [];
19
+ const { out } = run('npm audit --json', root);
20
+ let report;
21
+ try {
22
+ report = JSON.parse(out);
23
+ }
24
+ catch {
25
+ return [];
26
+ }
27
+ const findings = [];
28
+ for (const [name, entry] of Object.entries(report.vulnerabilities ?? {})) {
29
+ const via = (entry.via ?? []).find((v) => typeof v === 'object');
30
+ findings.push({
31
+ package: name,
32
+ version: String(entry.range ?? ''),
33
+ severity: String(entry.severity ?? 'unknown'),
34
+ id: String(via?.url?.split('/').pop() ?? via?.source ?? ''),
35
+ title: String(via?.title ?? `${name} has a known vulnerability`),
36
+ fixedIn: entry.fixAvailable && typeof entry.fixAvailable === 'object' ? String(entry.fixAvailable.version ?? '') : '',
37
+ source: 'npm audit',
38
+ dev: Boolean(entry.isDirect === false ? false : entry.dev ?? false) || Boolean(entry.effects?.length === 0 && entry.dev),
39
+ });
40
+ }
41
+ return findings;
42
+ }
43
+ /** `pip-audit`, when the project is Python and the tool is installed. */
44
+ export function pipAudit(root) {
45
+ const python = ['requirements.txt', 'pyproject.toml', 'setup.py'].some((f) => existsSync(join(root, f)));
46
+ if (!python || !has('pip-audit', root))
47
+ return [];
48
+ const { out } = run('pip-audit -f json --progress-spinner off', root);
49
+ try {
50
+ const report = JSON.parse(out);
51
+ const list = report.dependencies ?? report;
52
+ return (Array.isArray(list) ? list : []).flatMap((dep) => (dep.vulns ?? []).map((v) => ({
53
+ package: String(dep.name ?? ''),
54
+ version: String(dep.version ?? ''),
55
+ severity: String(v.severity ?? 'unknown').toLowerCase(),
56
+ id: String(v.id ?? ''),
57
+ title: String(v.description ?? '').split('\n')[0].slice(0, 200),
58
+ fixedIn: (v.fix_versions ?? []).join(', '),
59
+ source: 'pip-audit',
60
+ dev: false,
61
+ })));
62
+ }
63
+ catch {
64
+ return [];
65
+ }
66
+ }
67
+ /** OSV-Scanner, which covers Go, Rust, Java, Ruby, PHP and more, when it is installed. */
68
+ export function osvScan(root) {
69
+ if (!has('osv-scanner', root))
70
+ return [];
71
+ const { out } = run(`osv-scanner --format json --recursive "${root}"`, root);
72
+ try {
73
+ const report = JSON.parse(out);
74
+ const findings = [];
75
+ for (const result of report.results ?? []) {
76
+ for (const pkg of result.packages ?? []) {
77
+ for (const v of pkg.vulnerabilities ?? []) {
78
+ findings.push({
79
+ package: String(pkg.package?.name ?? ''),
80
+ version: String(pkg.package?.version ?? ''),
81
+ severity: String(v.database_specific?.severity ?? v.severity?.[0]?.type ?? 'unknown').toLowerCase(),
82
+ id: String(v.id ?? ''),
83
+ title: String(v.summary ?? '').slice(0, 200),
84
+ fixedIn: '',
85
+ source: 'osv-scanner',
86
+ dev: false,
87
+ });
88
+ }
89
+ }
90
+ }
91
+ return findings;
92
+ }
93
+ catch {
94
+ return [];
95
+ }
96
+ }
97
+ export function auditDependencies(root) {
98
+ const ran = [];
99
+ const missing = [];
100
+ const findings = [];
101
+ if (existsSync(join(root, 'package.json'))) {
102
+ ran.push('npm audit');
103
+ findings.push(...npmAudit(root));
104
+ }
105
+ const python = ['requirements.txt', 'pyproject.toml', 'setup.py'].some((f) => existsSync(join(root, f)));
106
+ if (python) {
107
+ if (has('pip-audit', root)) {
108
+ ran.push('pip-audit');
109
+ findings.push(...pipAudit(root));
110
+ }
111
+ else {
112
+ missing.push('pip-audit (pip install pip-audit) for the Python dependencies');
113
+ }
114
+ }
115
+ if (has('osv-scanner', root)) {
116
+ ran.push('osv-scanner');
117
+ findings.push(...osvScan(root));
118
+ }
119
+ else if (!existsSync(join(root, 'package.json')) && !python) {
120
+ missing.push('osv-scanner (https://osv.dev) for this project\'s ecosystem');
121
+ }
122
+ return { findings: findings.sort(bySeverity), ran, missing };
123
+ }
@@ -0,0 +1,158 @@
1
+ // Phase 9: secrets left in a repository. This is the one security check Tewip does itself, because
2
+ // it needs no tool installed and no network: a credential either matches a shape its issuer
3
+ // publishes, or it does not.
4
+ //
5
+ // Two rules keep it honest. A finding must quote where it is, never what it is: the value is
6
+ // redacted everywhere, including in the report and in anything posted to CI. And a shape alone is
7
+ // not a risk - "AKIAIOSFODNN7EXAMPLE" is in Amazon's own documentation - so a match that looks like
8
+ // a placeholder, or that has too little randomness to be a real key, is reported as noise rather
9
+ // than as a leak.
10
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
11
+ import { join, relative, sep } from 'node:path';
12
+ /** Shapes their own issuers publish. Each one is a credential, not a guess about one. */
13
+ const PATTERNS = [
14
+ { kind: 'aws-access-key-id', what: 'an AWS access key id', re: /\b((?:AKIA|ASIA|AGPA|AIDA|AROA|ANPA)[A-Z0-9]{16})\b/g, minEntropy: 2.5 },
15
+ { kind: 'aws-secret-key', what: 'an AWS secret access key', re: /\baws_secret_access_key\s*[=:]\s*['"]?([A-Za-z0-9/+=]{40})\b/gi },
16
+ { kind: 'github-token', what: 'a GitHub token', re: /\b((?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,255}|github_pat_[A-Za-z0-9_]{60,})\b/g },
17
+ { kind: 'gitlab-token', what: 'a GitLab personal access token', re: /\b(glpat-[A-Za-z0-9_-]{20,})(?![A-Za-z0-9_-])/g },
18
+ { kind: 'slack-token', what: 'a Slack token', re: /\b(xox[baprs]-[A-Za-z0-9-]{10,})\b/g },
19
+ { kind: 'slack-webhook', what: 'a Slack incoming webhook', re: /(https:\/\/hooks\.slack\.com\/services\/[A-Za-z0-9/+]{20,})/g },
20
+ // Stripe writes both "live" and "prod" on real keys; a "test" key is not a credential, and is
21
+ // left alone on purpose. The prod form was missed until a labelled sample showed it.
22
+ { kind: 'stripe-key', what: 'a live Stripe key', re: /\b((?:sk|rk)_(?:live|prod)_[A-Za-z0-9]{20,})(?![A-Za-z0-9])/g },
23
+ // A key may end in "-", where a word boundary never matches: a real Google key was missed that
24
+ // way. And "sk-ant-..." is Anthropic's, not OpenAI's, so the looser shape must exclude it or one
25
+ // credential gets reported twice under two names.
26
+ { kind: 'openai-key', what: 'an OpenAI API key', re: /\b(sk-(?:proj-|svcacct-|admin-)?(?!ant-)[A-Za-z0-9_-]{32,})(?![A-Za-z0-9_-])/g },
27
+ { kind: 'anthropic-key', what: 'an Anthropic API key', re: /\b(sk-ant-(?:api|admin)[0-9]{2}-[A-Za-z0-9_-]{32,})(?![A-Za-z0-9_-])/g },
28
+ { kind: 'google-api-key', what: 'a Google API key', re: /\b(AIza[A-Za-z0-9_-]{35})(?![A-Za-z0-9_-])/g },
29
+ { kind: 'npm-token', what: 'an npm token', re: /\b(npm_[A-Za-z0-9]{36})\b/g },
30
+ { kind: 'pypi-token', what: 'a PyPI token', re: /\b(pypi-AgEIcHlwaS5vcmc[A-Za-z0-9_-]{50,})\b/g },
31
+ { kind: 'sendgrid-key', what: 'a SendGrid key', re: /\b(SG\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,})\b/g },
32
+ { kind: 'twilio-key', what: 'a Twilio key', re: /\b(SK[a-f0-9]{32})\b/g },
33
+ { kind: 'digitalocean-token', what: 'a DigitalOcean token', re: /\b(dop_v1_[a-f0-9]{64})\b/g },
34
+ { kind: 'private-key', what: 'a private key', re: /(-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY(?: BLOCK)?-----)/g, minEntropy: 0 },
35
+ { kind: 'connection-string', what: 'a database URL with a password in it', re: /\b((?:postgres|postgresql|mysql|mongodb(?:\+srv)?|redis|amqp):\/\/[^:\s'"]+:([^@\s'"]{6,})@[^\s'"]+)/g },
36
+ { kind: 'jwt', what: 'a signed token (JWT)', re: /\b(eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,})(?![A-Za-z0-9_-])/g },
37
+ // A password in a web address leaks the same way a database URL does, and is easier to miss.
38
+ { kind: 'url-credentials', what: 'a password inside a URL', re: /\b(https?:\/\/[^:/\s'"]+:([^@\s'"]{4,})@[^\s'"]+)/g, minEntropy: 2 },
39
+ ];
40
+ /** How random a value is, in bits per character. A real key is above 3; "xxxxxxxx" is 0. */
41
+ export function entropyOf(value) {
42
+ if (!value)
43
+ return 0;
44
+ const counts = new Map();
45
+ for (const ch of value)
46
+ counts.set(ch, (counts.get(ch) ?? 0) + 1);
47
+ let bits = 0;
48
+ for (const n of counts.values()) {
49
+ const p = n / value.length;
50
+ bits -= p * Math.log2(p);
51
+ }
52
+ return bits;
53
+ }
54
+ const PLACEHOLDER_WORDS = /example|placeholder|dummy|sample|changeme|your[-_ ]?(?:key|token|secret|api)|xxxx|redacted|fake|notreal|test[-_]?key|<[^>]+>|\$\{|\{\{/i;
55
+ /** Files whose whole purpose is to show the shape of a secret, not to hold one. */
56
+ const DOC_FILE = /(?:^|[\\/])(?:\.env\.example|\.env\.sample|\.env\.template|[^\\/]*\.(?:md|rst|txt|adoc)|[^\\/]*example[^\\/]*|[^\\/]*sample[^\\/]*)$/i;
57
+ /** Why this match is probably not a live credential, or null when nothing says so. */
58
+ export function looksPlanted(value, line, file) {
59
+ if (PLACEHOLDER_WORDS.test(value))
60
+ return 'the value itself says it is an example';
61
+ if (PLACEHOLDER_WORDS.test(line))
62
+ return 'the line around it says it is an example';
63
+ if (DOC_FILE.test(file))
64
+ return 'it is in a documentation or example file';
65
+ if (/(.)\1{5,}/.test(value))
66
+ return 'the value repeats one character, so it is a stand-in';
67
+ return null;
68
+ }
69
+ const SKIP_DIR = new Set(['node_modules', '.git', 'dist', 'build', 'out', 'coverage', 'target', 'vendor', '__pycache__', '.venv', 'venv', '.tewip', '.next', '.cache']);
70
+ const BINARY = /\.(?:png|jpe?g|gif|webp|ico|svg|pdf|zip|gz|tar|exe|dll|so|dylib|woff2?|ttf|eot|mp4|mp3|wav|class|jar|pyc|lock)$/i;
71
+ const MAX_FILE_BYTES = 2 * 1024 * 1024;
72
+ /** Every text file in a project, skipping what no repository owns. */
73
+ export function sourceFiles(root, limit = 5000) {
74
+ const out = [];
75
+ const walk = (dir) => {
76
+ if (out.length >= limit)
77
+ return;
78
+ let entries;
79
+ try {
80
+ entries = readdirSync(dir);
81
+ }
82
+ catch {
83
+ return;
84
+ }
85
+ for (const entry of entries) {
86
+ if (out.length >= limit)
87
+ return;
88
+ const path = join(dir, entry);
89
+ let info;
90
+ try {
91
+ info = statSync(path);
92
+ }
93
+ catch {
94
+ continue;
95
+ }
96
+ if (info.isDirectory()) {
97
+ if (!SKIP_DIR.has(entry))
98
+ walk(path);
99
+ continue;
100
+ }
101
+ if (BINARY.test(entry) || info.size > MAX_FILE_BYTES)
102
+ continue;
103
+ out.push(path);
104
+ }
105
+ };
106
+ walk(root);
107
+ return out;
108
+ }
109
+ /** The value, blanked: first two characters, then dots. Tewip never prints a credential. */
110
+ export function redact(line, value) {
111
+ const shown = value.slice(0, 2);
112
+ const hidden = `${shown}${'.'.repeat(Math.min(12, Math.max(4, value.length - 2)))}`;
113
+ return line.replace(value, hidden).trim().slice(0, 200);
114
+ }
115
+ export function scanSecrets(root, files = sourceFiles(root)) {
116
+ const findings = [];
117
+ const seen = new Set();
118
+ for (const file of files) {
119
+ let text;
120
+ try {
121
+ text = readFileSync(file, 'utf8');
122
+ }
123
+ catch {
124
+ continue;
125
+ }
126
+ if (text.includes('\u0000'))
127
+ continue; // binary after all
128
+ const lines = text.split('\n');
129
+ for (const { kind, what, re, minEntropy } of PATTERNS) {
130
+ for (const match of text.matchAll(re)) {
131
+ const value = match[1];
132
+ const entropy = entropyOf(value);
133
+ if (entropy < (minEntropy ?? 3))
134
+ continue;
135
+ const index = match.index ?? 0;
136
+ const line = text.slice(0, index).split('\n').length;
137
+ const rel = relative(root, file).split(sep).join('/');
138
+ // One credential, one finding: a value can match a loose shape and a tighter one, and the
139
+ // same key reported under two names reads as two leaks.
140
+ const key = `${rel}:${line}:${value}`;
141
+ if (seen.has(key) || seen.has(`${rel}:${line}`))
142
+ continue;
143
+ seen.add(key);
144
+ seen.add(`${rel}:${line}`);
145
+ findings.push({
146
+ kind,
147
+ what,
148
+ file: rel,
149
+ line,
150
+ evidence: redact(lines[line - 1] ?? '', value),
151
+ entropy: Number(entropy.toFixed(2)),
152
+ placeholder: looksPlanted(value, lines[line - 1] ?? '', rel),
153
+ });
154
+ }
155
+ }
156
+ }
157
+ return findings;
158
+ }
@@ -0,0 +1,79 @@
1
+ // `tewip secure`: run the scanners a project already has, then say which of their findings matter.
2
+ //
3
+ // It costs nothing (no model is called), it never prints a credential, and it never claims a
4
+ // project is secure: it says exactly what was checked, what was not, and what it decided about each
5
+ // finding. "No real risk found" is a statement about these checks, not a certificate.
6
+ import { mkdirSync, writeFileSync } from 'node:fs';
7
+ import { dirname, join, resolve } from 'node:path';
8
+ import { auditDependencies } from './deps.js';
9
+ import { scanSecrets, sourceFiles } from './secrets.js';
10
+ import { readAccepted, triageDependency, triageSecret, worstFirst } from './triage.js';
11
+ const CODE = /\.(?:[cm]?[jt]sx?|py|rb|go|java|kt|cs|php|rs|vue|svelte)$/;
12
+ export function renderSecurity(items, checked, notChecked, root) {
13
+ const real = items.filter((i) => i.verdict === 'real-risk');
14
+ const later = items.filter((i) => i.verdict === 'not-reachable');
15
+ const noise = items.filter((i) => i.verdict === 'noise');
16
+ const accepted = items.filter((i) => i.verdict === 'accepted');
17
+ const lines = ['# Tewip security check', ''];
18
+ lines.push(`Checked: ${checked.length > 0 ? checked.join(', ') : 'nothing ran'}.`);
19
+ if (notChecked.length > 0)
20
+ lines.push(`**Not checked**: ${notChecked.join('; ')}.`);
21
+ lines.push('');
22
+ lines.push(`${real.length} need someone now · ${later.length} real but not reachable · ${noise.length} noise · ${accepted.length} accepted earlier.`);
23
+ lines.push('');
24
+ const block = (title, group, explain) => {
25
+ if (group.length === 0)
26
+ return;
27
+ lines.push(`## ${title} (${group.length})`, '', explain, '');
28
+ for (const item of group) {
29
+ lines.push(`- **${item.title}** · ${item.severity}`);
30
+ lines.push(` - Why: ${item.why}`);
31
+ lines.push(` - Do: ${item.next}`);
32
+ lines.push(` - Found by: ${item.source}`);
33
+ }
34
+ lines.push('');
35
+ };
36
+ block('Act on these', real, 'Each one is committed or reachable, and the evidence is beside it.');
37
+ block('Real, but not reachable here', later, 'True of the repository, not of what it runs: fix them on a normal schedule, not tonight.');
38
+ block('Noise', noise, 'A shape without a credential behind it. Listed so you can see nothing was hidden.');
39
+ block('Accepted earlier', accepted, `From ${join('.tewip', 'security-accepted.txt')}, written by a person.`);
40
+ lines.push('---', '');
41
+ lines.push('Tewip never prints a credential: a secret is located, never quoted. To accept a finding,');
42
+ lines.push(`add a line to \`${join(root, '.tewip', 'security-accepted.txt')}\`, for example:`);
43
+ lines.push('```', 'secret tests/fixtures/sample.env:3 - a fixture, not a real key', 'dependency lodash - waiting for the upstream fix', '```');
44
+ lines.push('');
45
+ lines.push('This is not a certificate. It is the result of the checks named above, and nothing else.');
46
+ return lines.join('\n');
47
+ }
48
+ export async function runSecure(cmd) {
49
+ const root = resolve(cmd.root);
50
+ const accepted = readAccepted(root);
51
+ const files = sourceFiles(root);
52
+ const items = [];
53
+ const checked = ['secrets in the working tree'];
54
+ const notChecked = [];
55
+ for (const finding of scanSecrets(root, files))
56
+ items.push(triageSecret(finding, root, accepted));
57
+ if (!cmd.secretsOnly) {
58
+ const audit = auditDependencies(root);
59
+ checked.push(...audit.ran);
60
+ notChecked.push(...audit.missing);
61
+ const sources = files.filter((f) => CODE.test(f));
62
+ for (const finding of audit.findings)
63
+ items.push(triageDependency(finding, root, sources, accepted));
64
+ }
65
+ else {
66
+ notChecked.push('dependencies (--secrets-only was asked for)');
67
+ }
68
+ // A web scan is a different kind of check and needs a running app; it is not pretended here.
69
+ notChecked.push('a running application (no web scan is part of this command yet)');
70
+ items.sort(worstFirst);
71
+ const report = renderSecurity(items, checked, notChecked, root);
72
+ console.log(report);
73
+ if (cmd.out) {
74
+ mkdirSync(dirname(resolve(cmd.out)), { recursive: true });
75
+ writeFileSync(resolve(cmd.out), `${report}\n`, 'utf8');
76
+ console.log(`\nWritten to ${cmd.out}`);
77
+ }
78
+ return items;
79
+ }
@@ -0,0 +1,133 @@
1
+ // Phase 9: the part scanners do not do. A scanner says "this package has a CVE" or "this looks like
2
+ // an AWS key"; a person then spends an afternoon deciding which of those matter. This decides, the
3
+ // same way the rest of Tewip decides: deterministic checks first, evidence with every verdict, and
4
+ // a plain next step.
5
+ //
6
+ // The verdicts are deliberately few:
7
+ // real-risk something a person should act on now, with the evidence that makes it real;
8
+ // not-reachable true of the repository, but not of what it runs: a dev-only dependency, or a
9
+ // package nothing imports;
10
+ // noise a shape without a secret behind it: documentation, a fixture, a placeholder;
11
+ // accepted a person already looked and said so.
12
+ //
13
+ // A secret is never printed, only located. An "accepted" verdict is never invented by Tewip - it
14
+ // comes from a file a person wrote.
15
+ import { existsSync, readFileSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+ import { spawnSync } from 'node:child_process';
18
+ /** Findings a person has already judged, one per line: "<kind> <where> - reason". */
19
+ export function readAccepted(root) {
20
+ const file = join(root, '.tewip', 'security-accepted.txt');
21
+ if (!existsSync(file))
22
+ return new Set();
23
+ return new Set(readFileSync(file, 'utf8')
24
+ .split('\n')
25
+ .map((l) => l.split(' - ')[0].trim())
26
+ .filter(Boolean));
27
+ }
28
+ /** Whether git tracks this file. A secret in an ignored file never left the machine. */
29
+ export function isTracked(root, file) {
30
+ const r = spawnSync(`git ls-files --error-unmatch "${file}"`, { cwd: root, shell: true, encoding: 'utf8', timeout: 30_000 });
31
+ return r.status === 0;
32
+ }
33
+ const SECRET_SEVERITY = {
34
+ 'aws-secret-key': 'critical',
35
+ 'aws-access-key-id': 'critical',
36
+ 'private-key': 'critical',
37
+ 'stripe-key': 'critical',
38
+ 'github-token': 'high',
39
+ 'gitlab-token': 'high',
40
+ 'npm-token': 'high',
41
+ 'openai-key': 'high',
42
+ 'anthropic-key': 'high',
43
+ 'slack-token': 'high',
44
+ 'connection-string': 'high',
45
+ 'google-api-key': 'medium',
46
+ 'jwt': 'medium',
47
+ };
48
+ export function triageSecret(finding, root, accepted) {
49
+ const where = `${finding.file}:${finding.line}`;
50
+ const key = `secret ${where}`;
51
+ const base = {
52
+ kind: 'secret',
53
+ title: `${finding.what} in ${where}`,
54
+ severity: SECRET_SEVERITY[finding.kind] ?? 'medium',
55
+ where,
56
+ source: 'tewip secret scan',
57
+ };
58
+ if (accepted.has(key))
59
+ return { ...base, verdict: 'accepted', why: 'a person marked this one accepted in .tewip/security-accepted.txt', next: 'nothing: it was reviewed' };
60
+ if (finding.placeholder) {
61
+ return { ...base, verdict: 'noise', severity: 'low', why: `${finding.placeholder}: ${finding.evidence}`, next: 'nothing: this is not a credential' };
62
+ }
63
+ const tracked = isTracked(root, finding.file);
64
+ if (!tracked) {
65
+ return {
66
+ ...base,
67
+ verdict: 'not-reachable',
68
+ severity: 'low',
69
+ why: `git does not track ${finding.file}, so this never left the machine: ${finding.evidence}`,
70
+ next: `keep it that way: make sure ${finding.file} stays in .gitignore`,
71
+ };
72
+ }
73
+ return {
74
+ ...base,
75
+ verdict: 'real-risk',
76
+ why: `it is committed, and the value has the randomness of a real credential (${finding.entropy} bits per character): ${finding.evidence}`,
77
+ next: `revoke it at the issuer first, then remove it from the file and from the history. Changing the file alone is not enough: the old value stays in every clone.`,
78
+ };
79
+ }
80
+ /** Does anything in this project actually import the package? */
81
+ export function isImported(root, pkg, sources) {
82
+ const name = pkg.replace(/^npm:/, '');
83
+ const patterns = [`from '${name}'`, `from "${name}"`, `require('${name}')`, `require("${name}")`, `import ${name}`, `import '${name}'`, `from ${name} import`];
84
+ for (const file of sources) {
85
+ let text;
86
+ try {
87
+ text = readFileSync(file, 'utf8');
88
+ }
89
+ catch {
90
+ continue;
91
+ }
92
+ if (patterns.some((p) => text.includes(p)))
93
+ return true;
94
+ }
95
+ return false;
96
+ }
97
+ const DEP_SEVERITY = { critical: 'critical', high: 'high', moderate: 'medium', medium: 'medium', low: 'low' };
98
+ export function triageDependency(finding, root, sources, accepted) {
99
+ const where = `${finding.package}@${finding.version}`;
100
+ const base = {
101
+ kind: 'dependency',
102
+ title: `${finding.title} (${where})`,
103
+ severity: DEP_SEVERITY[finding.severity.toLowerCase()] ?? 'medium',
104
+ where,
105
+ source: finding.source,
106
+ };
107
+ const upgrade = finding.fixedIn ? `upgrade ${finding.package} to ${finding.fixedIn}` : `no fixed version is published yet: watch ${finding.id || 'the advisory'} and consider replacing the package`;
108
+ if (accepted.has(`dependency ${finding.package}`) || accepted.has(`dependency ${where}`)) {
109
+ return { ...base, verdict: 'accepted', why: 'a person marked this one accepted in .tewip/security-accepted.txt', next: 'nothing: it was reviewed' };
110
+ }
111
+ if (finding.dev) {
112
+ return {
113
+ ...base,
114
+ verdict: 'not-reachable',
115
+ severity: base.severity === 'critical' ? 'medium' : 'low',
116
+ why: 'it is a development dependency, so it is not in what users run',
117
+ next: `${upgrade} when convenient; it does not block a release`,
118
+ };
119
+ }
120
+ if (sources.length > 0 && !isImported(root, finding.package, sources)) {
121
+ return {
122
+ ...base,
123
+ verdict: 'not-reachable',
124
+ severity: base.severity === 'critical' ? 'medium' : 'low',
125
+ why: `nothing in this project imports ${finding.package}: it is pulled in by another package, so the vulnerable code may never run here`,
126
+ next: `${upgrade}, or check which dependency requires it (npm why ${finding.package}) before spending time on it`,
127
+ };
128
+ }
129
+ return { ...base, verdict: 'real-risk', why: `this project imports ${finding.package} directly, and the advisory applies to the installed version`, next: upgrade };
130
+ }
131
+ const ORDER = { 'real-risk': 0, 'not-reachable': 1, noise: 2, accepted: 3 };
132
+ const SEV = { critical: 0, high: 1, medium: 2, low: 3 };
133
+ export const worstFirst = (a, b) => ORDER[a.verdict] - ORDER[b.verdict] || SEV[a.severity] - SEV[b.severity];
@@ -8,19 +8,26 @@
8
8
  //
9
9
  // Settings: TEWIP_JIRA_URL (https://your-team.atlassian.net), TEWIP_JIRA_EMAIL, TEWIP_JIRA_TOKEN
10
10
  // (an API token from id.atlassian.com).
11
+ import { criteriaSection, wikiToText } from './wiki-text.js';
11
12
  /** The configuration from the environment, or null when Jira is not set up. */
12
13
  export function jiraFromEnv() {
13
14
  const baseUrl = process.env.TEWIP_JIRA_URL?.trim().replace(/\/+$/, '');
14
- const email = process.env.TEWIP_JIRA_EMAIL?.trim();
15
- const token = process.env.TEWIP_JIRA_TOKEN?.trim();
16
- return baseUrl && email && token ? { baseUrl, email, token } : null;
15
+ if (!baseUrl)
16
+ return null;
17
+ const email = process.env.TEWIP_JIRA_EMAIL?.trim() ?? '';
18
+ const token = process.env.TEWIP_JIRA_TOKEN?.trim() ?? '';
19
+ // A tracker that anyone can read - Apache's, Jenkins', many company ones - needs no account at
20
+ // all, and reading a ticket to plan from it is the commonest thing Tewip does with Jira.
21
+ return { baseUrl, email, token };
17
22
  }
18
23
  export function jiraHelp() {
19
24
  return [
20
- 'Jira is not set up. Three settings, all from your own account:',
21
- ' TEWIP_JIRA_URL=https://your-team.atlassian.net',
25
+ 'Jira is not set up. One setting to read a public tracker:',
26
+ ' TEWIP_JIRA_URL=https://issues.apache.org/jira (or your own Jira, Cloud or Server)',
27
+ 'Two more to let Tewip write test cases back into your own project:',
22
28
  ' TEWIP_JIRA_EMAIL=you@company.com',
23
- ' TEWIP_JIRA_TOKEN=... (create one at id.atlassian.com/manage-profile/security/api-tokens)',
29
+ ' TEWIP_JIRA_TOKEN=... (Cloud: id.atlassian.com/manage-profile/security/api-tokens;',
30
+ ' Server or Data Center: your profile > Personal Access Tokens)',
24
31
  ].join('\n');
25
32
  }
26
33
  /** Atlassian's document format is a tree of nodes; this takes the words out of it. */
@@ -38,54 +45,95 @@ export function plainText(node) {
38
45
  const FIGMA_LINK = /https:\/\/(?:www\.)?figma\.com\/[^\s)"']+/g;
39
46
  /** Teams keep acceptance criteria under many headings; these are the common ones. */
40
47
  const CRITERIA_HEADING = /(?:^|\n)\s*(?:acceptance criteria|ac|definition of done|dod)\s*:?\s*\n/i;
48
+ /**
49
+ * A ticket as Tewip reads it, from the raw fields. Kept apart from the client so it can be checked
50
+ * against real tickets without a network or an account.
51
+ */
52
+ export function readIssue(raw) {
53
+ const fields = raw.fields;
54
+ // Jira Cloud sends a document tree; Server and Data Center send wiki markup in a string.
55
+ const description = (typeof fields.description === 'string' ? wikiToText(fields.description) : plainText(fields.description)).trim();
56
+ // Some projects have a custom field for acceptance criteria; others put a heading in the body.
57
+ const custom = Object.entries(fields).find(([name, value]) => /customfield/.test(name) && value && /acceptance/i.test(JSON.stringify(value).slice(0, 200)));
58
+ const fromHeading = criteriaSection(description);
59
+ return {
60
+ key: raw.key,
61
+ summary: String(fields.summary ?? ''),
62
+ description,
63
+ acceptanceCriteria: fromHeading || (custom ? plainText(custom[1]).trim() : ''),
64
+ type: String((fields.issuetype ?? {}).name ?? ''),
65
+ status: String((fields.status ?? {}).name ?? ''),
66
+ labels: fields.labels ?? [],
67
+ designLinks: [...new Set(`${description} ${JSON.stringify(fields.attachment ?? '')}`.match(FIGMA_LINK) ?? [])],
68
+ };
69
+ }
41
70
  export class Jira {
42
71
  config;
72
+ /**
73
+ * Cloud speaks /rest/api/3 and wants a document tree; Server and Data Center speak /rest/api/2
74
+ * and want plain wiki markup. Most companies run the second kind, so both are supported: the
75
+ * version is guessed from the address and corrected the first time the other one answers.
76
+ */
77
+ version;
43
78
  constructor(config) {
44
79
  this.config = config;
80
+ this.version = /atlassian\.net$/i.test(new URL(config.baseUrl).hostname) ? 3 : 2;
81
+ }
82
+ /** Whether this site turned out to be Cloud, for anything that has to send a description. */
83
+ get isCloud() {
84
+ return this.version === 3;
45
85
  }
46
86
  async request(method, path, body) {
47
- const auth = Buffer.from(`${this.config.email}:${this.config.token}`).toString('base64');
48
- const res = await fetch(`${this.config.baseUrl}${path}`, {
87
+ // No credentials means an anonymous read: the header is left off entirely, because an empty
88
+ // one is rejected where no header at all is allowed.
89
+ const auth = this.config.token ? { Authorization: `Basic ${Buffer.from(`${this.config.email}:${this.config.token}`).toString('base64')}` } : {};
90
+ const send = async (version) => fetch(`${this.config.baseUrl}/rest/api/${version}${path}`, {
49
91
  method,
50
- headers: { Authorization: `Basic ${auth}`, Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}) },
51
- body: body ? JSON.stringify(body) : undefined,
92
+ headers: { ...auth, Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}) },
93
+ body: body ? JSON.stringify(this.bodyFor(version, body)) : undefined,
52
94
  });
95
+ let res = await send(this.version);
96
+ // The wrong guess answers 404 (no such endpoint) or 410 (gone); the other version is then tried
97
+ // once and remembered, so a Server site does not pay for the guess on every call.
98
+ if ((res.status === 404 || res.status === 410) && method === 'GET') {
99
+ const other = this.version === 3 ? 2 : 3;
100
+ const second = await send(other);
101
+ if (second.ok) {
102
+ this.version = other;
103
+ res = second;
104
+ }
105
+ }
53
106
  if (!res.ok)
54
- throw new Error(`Jira ${method} ${path} -> ${res.status}: ${(await res.text()).slice(0, 300)}`);
107
+ throw new Error(`Jira ${method} /rest/api/${this.version}${path} -> ${res.status}: ${(await res.text()).slice(0, 300)}`);
55
108
  return (res.status === 204 ? undefined : await res.json());
56
109
  }
110
+ /** A description is a document tree on Cloud and a string of wiki markup everywhere else. */
111
+ bodyFor(version, body) {
112
+ const fields = body.fields;
113
+ if (!fields || typeof fields.description !== 'string')
114
+ return body;
115
+ if (version === 2)
116
+ return body;
117
+ return { ...body, fields: { ...fields, description: { type: 'doc', version: 1, content: [{ type: 'paragraph', content: [{ type: 'text', text: String(fields.description).slice(0, 30_000) }] }] } } };
118
+ }
57
119
  /** One ticket, flattened into the facts a plan can be built from. */
58
120
  async issue(key) {
59
- const raw = await this.request('GET', `/rest/api/3/issue/${encodeURIComponent(key)}`);
60
- const fields = raw.fields;
61
- const description = plainText(fields.description).trim();
62
- // Some projects have a custom field for acceptance criteria; others put a heading in the body.
63
- const custom = Object.entries(fields).find(([name, value]) => /customfield/.test(name) && value && /acceptance/i.test(JSON.stringify(value).slice(0, 200)));
64
- const fromHeading = CRITERIA_HEADING.test(description) ? description.split(CRITERIA_HEADING).slice(1).join('\n').trim() : '';
65
- return {
66
- key: raw.key,
67
- summary: String(fields.summary ?? ''),
68
- description,
69
- acceptanceCriteria: fromHeading || (custom ? plainText(custom[1]).trim() : ''),
70
- type: String((fields.issuetype ?? {}).name ?? ''),
71
- status: String((fields.status ?? {}).name ?? ''),
72
- labels: fields.labels ?? [],
73
- designLinks: [...new Set(`${description} ${JSON.stringify(fields.attachment ?? '')}`.match(FIGMA_LINK) ?? [])],
74
- };
121
+ const raw = await this.request('GET', `/issue/${encodeURIComponent(key)}`);
122
+ return readIssue(raw);
75
123
  }
76
124
  /** The issue types a project actually has, so Tewip can pick one that exists rather than guess. */
77
125
  async issueTypes(projectKey) {
78
- const meta = await this.request('GET', `/rest/api/3/issue/createmeta?projectKeys=${encodeURIComponent(projectKey)}`);
126
+ const meta = await this.request('GET', `/issue/createmeta?projectKeys=${encodeURIComponent(projectKey)}&expand=projects.issuetypes`);
79
127
  return (meta.projects?.[0]?.issuetypes ?? []).map((t) => t.name);
80
128
  }
81
129
  /** Creates one issue and returns its key. Nothing else about it is ever changed afterwards. */
82
130
  async createIssue(input) {
83
- const created = await this.request('POST', '/rest/api/3/issue', {
131
+ const created = await this.request('POST', '/issue', {
84
132
  fields: {
85
133
  project: { key: input.projectKey },
86
134
  issuetype: { name: input.type },
87
135
  summary: input.summary.slice(0, 250),
88
- description: { type: 'doc', version: 1, content: [{ type: 'paragraph', content: [{ type: 'text', text: input.description.slice(0, 30_000) }] }] },
136
+ description: input.description.slice(0, 30_000),
89
137
  ...(input.labels?.length ? { labels: input.labels } : {}),
90
138
  ...(input.parentKey ? { parent: { key: input.parentKey } } : {}),
91
139
  },
@@ -94,12 +142,12 @@ export class Jira {
94
142
  }
95
143
  /** Links a created issue to the ticket it came from, the only relationship Tewip makes. */
96
144
  async link(fromKey, toKey, type = 'Relates') {
97
- await this.request('POST', '/rest/api/3/issueLink', { type: { name: type }, inwardIssue: { key: fromKey }, outwardIssue: { key: toKey } });
145
+ await this.request('POST', '/issueLink', { type: { name: type }, inwardIssue: { key: fromKey }, outwardIssue: { key: toKey } });
98
146
  }
99
147
  /** Issues Tewip created before, so the same scenario is never filed twice. */
100
148
  async findByLabel(projectKey, label) {
101
149
  const jql = `project = ${projectKey} AND labels = ${label} ORDER BY created DESC`;
102
- const found = await this.request('GET', `/rest/api/3/search?jql=${encodeURIComponent(jql)}&maxResults=100&fields=summary`);
150
+ const found = await this.request('GET', `/search?jql=${encodeURIComponent(jql)}&maxResults=100&fields=summary`);
103
151
  return (found.issues ?? []).map((i) => ({ key: i.key, summary: i.fields.summary }));
104
152
  }
105
153
  }
@@ -0,0 +1,74 @@
1
+ // Jira Cloud stores a description as a tree (Atlassian Document Format). Jira Server and Data
2
+ // Center - which is what most companies still run, and what the public Apache and Jenkins trackers
3
+ // use - store it as one string of wiki markup. Tewip has to read both, or half the tickets in the
4
+ // world come out full of `h2.`, `{code}` and `[~someone]`.
5
+ //
6
+ // Measured on 150 real tickets from those two trackers: before this, 75 of 150 descriptions came
7
+ // out as readable text.
8
+ /** A character no ticket contains, holding a heading's place while lists are converted. */
9
+ const MARK = String.fromCharCode(1);
10
+ /** A heading, so the section it starts can be found afterwards: kept as "## Words". */
11
+ const HEADING = /^\s*h([1-6])\.\s*(.+)$/gm;
12
+ /** Decorations around words: *bold*, _italic_, +inserted+, -struck-, ??cited??, ^sup^, ~sub~. */
13
+ const stripDecorations = (text) => text
14
+ .replace(/\{\{([^}]*)\}\}/g, '$1')
15
+ .replace(/(^|[\s(])([*_+])(?=\S)([^\n]*?\S)\2(?=[\s.,;:)!?]|$)/g, '$1$3')
16
+ .replace(/(^|[\s(])-(?=\S)([^\n]*?\S)-(?=[\s.,;:)!?]|$)/g, '$1$2')
17
+ .replace(/\?\?([^?]+)\?\?/g, '$1');
18
+ export function wikiToText(markup) {
19
+ let text = markup.replace(/\r\n?/g, '\n');
20
+ // Blocks first: their contents are kept, their fences are not.
21
+ text = text
22
+ .replace(/\{code(?::[^}]*)?\}\n?([\s\S]*?)\{code\}/g, (_m, body) => `\n${body.trim()}\n`)
23
+ .replace(/\{noformat\}\n?([\s\S]*?)\{noformat\}/g, (_m, body) => `\n${body.trim()}\n`)
24
+ .replace(/\{quote\}\n?([\s\S]*?)\{quote\}/g, (_m, body) => `\n${body.trim()}\n`)
25
+ .replace(/\{panel(?::[^}]*)?\}\n?([\s\S]*?)\{panel\}/g, (_m, body) => `\n${body.trim()}\n`)
26
+ .replace(/\{color(?::[^}]*)?\}/g, '')
27
+ .replace(/\{anchor:[^}]*\}/g, '')
28
+ .replace(/\{[a-z-]+(?::[^}]*)?\}/g, ''); // any other macro fence
29
+ // Headings become "## Words", which is how the rest of Tewip recognises a section. They are
30
+ // marked first and written out last: in wiki markup a numbered list item also starts with "#",
31
+ // and converting headings straight to "##" turned every heading into a bullet.
32
+ text = text.replace(HEADING, (_m, level, words) => `\n${MARK}${level} ${stripDecorations(words).trim().replace(/[:\s]+$/, '')}`);
33
+ text = text
34
+ .replace(/\[([^\]|]+)\|([^\]]+)\]/g, '$1 ($2)') // [text|https://...]
35
+ .replace(/\[~([\w.@-]+)\]/g, '@$1') // a person
36
+ .replace(/\[([^\]|]{1,80})\]/g, '$1') // [SOME-123]
37
+ .replace(/^\s*\|\|(.+)\|\|\s*$/gm, (_m, row) => row.split('||').map((c) => c.trim()).filter(Boolean).join(' | '))
38
+ .replace(/^\s*\|(.+)\|\s*$/gm, (_m, row) => row.split('|').map((c) => c.trim()).filter(Boolean).join(' | '))
39
+ .replace(/^\s*[*#]{1,4}\s+/gm, '- ') // bullet and numbered lists
40
+ .replace(/\\\\\s*$/gm, '') // a forced line break
41
+ .replace(/^\s*----+\s*$/gm, '');
42
+ // The headings, written out now that nothing else can be mistaken for them.
43
+ text = text.replace(new RegExp(`${MARK}([1-6]) `, 'g'), (_m, level) => `${'#'.repeat(Number(level))} `);
44
+ return stripDecorations(text)
45
+ .split('\n')
46
+ .map((line) => line.replace(/\s+$/, ''))
47
+ .join('\n')
48
+ .replace(/\n{3,}/g, '\n\n')
49
+ .trim();
50
+ }
51
+ /**
52
+ * The heading that starts the acceptance criteria. Real tickets write it as "h2. Acceptance
53
+ * Criteria", "h1. +*Acceptance criteria*+", "*Acceptance criteria (Given / When / Then):*",
54
+ * "Suggested acceptance criteria" and "Definition of Done" - all of these are from the corpus.
55
+ */
56
+ const CRITERIA_LINE = /^\s*(?:#{1,6}\s*)?(?:suggested|proposed|draft)?\s*(?:acceptance\s+criteria|acceptance|definition\s+of\s+done|dod)\b[^\n]*$/i;
57
+ /** A line that starts a different section, which ends the criteria. */
58
+ const OTHER_HEADING = /^#{1,6}\s+\S/;
59
+ /** The acceptance criteria written in a description, or '' when the author wrote none. */
60
+ export function criteriaSection(text) {
61
+ const lines = text.split('\n');
62
+ const start = lines.findIndex((line) => CRITERIA_LINE.test(line) && /acceptance|definition\s+of\s+done|dod/i.test(line));
63
+ if (start < 0)
64
+ return '';
65
+ const body = [];
66
+ for (let i = start + 1; i < lines.length; i++) {
67
+ if (OTHER_HEADING.test(lines[i]))
68
+ break;
69
+ body.push(lines[i]);
70
+ }
71
+ const section = body.join('\n').trim();
72
+ // A heading with nothing under it is not criteria; nor is one whose "criteria" is a single word.
73
+ return section.length > 10 ? section : '';
74
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tewip-ai",
3
- "version": "0.6.0",
3
+ "version": "0.7.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",