Files
stack/agents/dewey/work/queue-74/mutants.py
T

68 lines
4.7 KiB
Python

#!/usr/bin/env python3
# Dewey row 74 mutants: single-site edits in the mutant worktree (never the build tree),
# each run against the preview and serve tests, then restored from the index.
import os, subprocess, sys
R = os.path.expanduser('~/dewey-scratch/r74')
T = R + '/mut'
OUT = R + '/out'
SERVE = 'packages/webui/src/serve.mjs'
CLI = 'packages/webui/src/cli.mjs'
TESTS = ['node', '--test', 'packages/webui/tests/preview.test.mjs', 'packages/webui/tests/serve.test.mjs']
MUTANTS = [
('partial-allowed', SERVE, "if (given.length !== 3) throw", "if (given.length < 1) throw", TESTS),
('host-not-lowercased', SERVE, "const host = String(publicHost).toLowerCase(), header", "const host = String(publicHost), header", TESTS),
('header-not-lowercased', SERVE, "header = String(authHeader).toLowerCase();", "header = String(authHeader);", TESTS),
('ip-allowed', SERVE, "if (!HOSTNAME.test(host) || isIP(host)) throw", "if (!HOSTNAME.test(host)) throw", TESTS),
('host-header-allowed', SERVE, "['host', 'origin'].includes(header)", "['origin'].includes(header)", TESTS),
('user-comma-allowed', SERVE, "const USER = /^[\\x21-\\x2b\\x2d-\\x7e]{1,256}$/;", "const USER = /^[\\x21-\\x7e]{1,256}$/;", TESTS),
('empty-object-serves', SERVE, "if (preview !== null && !(preview = previewOptions(", "if (preview !== null && false && !(preview = previewOptions(", TESTS),
('named-case-sensitive', SERVE, "(req.headers.host ?? '').toLowerCase() === preview.host", "(req.headers.host ?? '') === preview.host", TESTS),
('named-prefix', SERVE, "(req.headers.host ?? '').toLowerCase() === preview.host", "(req.headers.host ?? '').toLowerCase().startsWith(preview.host)", TESTS),
('named-hostname-only', SERVE, "(req.headers.host ?? '').toLowerCase() === preview.host", "(req.headers.host ?? '').toLowerCase().split(':')[0] === preview.host", TESTS),
('origin-http-allowed', SERVE, "(named ? `https://${preview.host}` : `http://${req.headers.host}`)", "(named ? `http://${preview.host}` : `http://${req.headers.host}`)", TESTS),
('origin-public-on-loopback', SERVE, "req.headers.origin !== (named ? `https://${preview.host}`", "req.headers.origin !== `https://${preview?.host}` && req.headers.origin !== (named ? `https://${preview.host}`", TESTS),
('header-check-skipped', SERVE, " if (named) {\n // One value", " if (false) {\n // One value", TESTS),
('two-values-allowed', SERVE, "values.length !== 1 || values[0] !== preview.user", "!values.includes(preview.user)", TESTS),
('value-prefix', SERVE, "values[0] !== preview.user", "!values[0].startsWith(preview.user)", TESTS),
('value-case-insensitive', SERVE, "values[0] !== preview.user", "values[0].toLowerCase() !== preview.user.toLowerCase()", TESTS),
('header-check-raw-name', SERVE, "req.headersDistinct[preview.header]", "req.headersDistinct[preview.header.toUpperCase()]", TESTS),
('readonly-named-only', SERVE, "if (preview !== null && req.method !== 'GET' && req.method !== 'HEAD') return", "if (named && req.method !== 'GET' && req.method !== 'HEAD') return", TESTS),
('readonly-post-only', SERVE, "if (preview !== null && req.method !== 'GET' && req.method !== 'HEAD') return", "if (preview !== null && req.method === 'POST') return", TESTS),
('readonly-always', SERVE, "if (preview !== null && req.method !== 'GET' && req.method !== 'HEAD') return", "if (req.method !== 'GET' && req.method !== 'HEAD') return", TESTS),
('cli-preview-dropped', CLI, "bus, reads, preview: options.preview });", "bus, reads });", TESTS),
('cli-checked-late', CLI, " options.preview = previewOptions(preview);\n", "", TESTS),
]
def run(cmd):
try:
p = subprocess.run(cmd, cwd=T, capture_output=True, text=True, timeout=120, env={**os.environ, 'TMPDIR': R + '/tmp'})
except subprocess.TimeoutExpired as e:
return 'timeout', str(e.stdout or '')
return p.returncode, p.stdout + p.stderr
want = sys.argv[1:]
results = []
for name, f, old, new, tests in MUTANTS:
if want and name not in want:
continue
path = os.path.join(T, f)
src = open(path).read()
if src.count(old) != 1:
results.append(f'{name} BAD-SITE ({src.count(old)} matches)')
print(results[-1], flush=True)
continue
open(path, 'w').write(src.replace(old, new))
try:
rc, out = run(tests)
finally:
subprocess.run(['git', 'checkout', '--', f], cwd=T, check=True)
open(f'{OUT}/mut-{name}.txt', 'w').write(out)
results.append(f"{name} {'killed (timeout)' if rc == 'timeout' else 'killed' if rc else 'SURVIVED'}")
print(results[-1], flush=True)
rc, diff = run(['git', 'diff', '--stat'])
print('worktree diff after:', diff.strip() or 'clean')
killed = sum(1 for r in results if ' killed' in r)
print(f'summary {killed} of {len(results)} killed')