# Row 89 mutants (Dewey): each swaps one exact string in the scratch copy of the candidate, runs # the bus or harness node tests, restores the file, and reports killed (tests fail) or SURVIVED. import subprocess, sys, os, signal M = os.path.expanduser('~/dewey-scratch/r89mut') env = dict(os.environ, TMPDIR=os.path.expanduser('~/dewey-scratch/tmp')) B = 'packages/bus/src/broker.mjs' T = 'packages/harness/src/tools.mjs' MCP = 'packages/harness/src/mcp-server.mjs' PI = 'packages/harness/src/pi-extension.mjs' BU = 'packages/harness/src/bundle.mjs' BUS = ['packages/bus/tests/*.test.mjs'] HAR = ['packages/harness/tests/*.test.mjs'] muts = [ ('no args filtered too', B, " if (!Object.keys(a).length) return rows;\n", "", BUS), ('tombstones kept', B, " .filter((r) => !r.fields.gone)\n", "", BUS), ('next on an exact last page', B, "rest.length > limit ?", "rest.length >= limit ?", BUS), ('default limit ignored', B, "const limit = a.limit ?? TASK_PAGE;", "const limit = a.limit ?? TASK_PAGE_MAX;", BUS), ('limit max unchecked', B, "!positive(a.limit, TASK_PAGE_MAX)", "!positive(a.limit)", BUS), ('assignee by raw id', B, ".filter((u) => bots.has(u)).map((u) => bots.get(u)).sort()", ".sort()", BUS), ('assignees keep persons', B, ".filter((u) => bots.has(u)).map((u) => bots.get(u))", ".map((u) => bots.get(u) ?? u)", BUS), ('task_ref with other args', B, "Object.keys(a).length !== 1 || ", "", BUS), ('missing task_ref is null', B, "?? fail('task-not-found')", "?? null", BUS), ('done check dropped', B, "('done' in a && typeof a.done !== 'boolean') ||", "", BUS), ('done filter dropped', B, "(!('done' in a) || t.done === a.done) &&", "", BUS), ('assignee any string', B, "!(typeof a.assignee === 'string' && Object.hasOwn(roles, a.assignee))", "typeof a.assignee !== 'string'", BUS), ('label check dropped', B, "('label' in a && !positive(a.label)) ||", "", BUS), ('label filter dropped', B, "(!('label' in a) || t.labels.includes(a.label)) &&", "", BUS), ('requirement pattern dropped', B, " && /^REQ-[A-Z]+-[1-9][0-9]*$/.test(a.requirement)", "", BUS), ('requirement filter dropped', B, "(!('requirement' in a) || t.requirement === a.requirement)", "true", BUS), ('after ignored', B, "matched.filter((t) => t.task_ref > a.after)", "matched", BUS), ('after inclusive', B, "t.task_ref > a.after", "t.task_ref >= a.after", BUS), ('total counts the page', B, "total: matched.length", "total: tasks.length", BUS), ('description kept', B, " title: f.title ?? null,\n", " title: f.title ?? null,\n description: f.description,\n", BUS), ('tasks keys closed', B, "verb === 'tasks' ? TASK_ARGS : []", "[]", BUS), ('ARG_DEFAULTS removed', T, "args: { ...ARG_DEFAULTS[tool], ...args }", "args", HAR), ('default overrides the caller', T, "args: { ...ARG_DEFAULTS[tool], ...args }", "args: { ...args, ...ARG_DEFAULTS[tool] }", HAR), ('cap at >=', T, "if (text.length > REPLY_CAP)", "if (text.length >= REPLY_CAP)", HAR), ('cap counts bytes', T, "if (text.length > REPLY_CAP)", "if (Buffer.byteLength(text) > REPLY_CAP)", HAR), ('indented JSON', T, "const text = JSON.stringify(result) ?? \"null\";", "const text = JSON.stringify(result, null, 2) ?? \"null\";", HAR), ('cap 50000', T, "export const REPLY_CAP = 40_000;", "export const REPLY_CAP = 50_000;", HAR + BUS), ('mcp server uncapped', MCP, "text: replyText(result)", "text: JSON.stringify(result)", HAR), ('pi uncapped', PI, "text: replyText(result)", "text: JSON.stringify(result)", HAR), ('tool limit max 500', T, "maximum: TASK_PAGE_MAX,", "maximum: 500,", HAR), ('get_task not required', T, "obj({ task_ref: TASK_REF }, [\"task_ref\"])", "obj({ task_ref: TASK_REF })", HAR), ('get_task without tracker', T, " add(\"get_task\", \"tasks\",", " }\n {\n add(\"get_task\", \"tasks\",", HAR), ('bundle line pi names', BU, "(session.harness === \"pi\" ? resolved.limits.tools : claudeBuiltins({ tools: resolved.limits.tools }))", "resolved.limits.tools", HAR), ('bundle none dropped', BU, ".join(\", \") || \"none\"}", ".join(\", \")}", HAR), ] only = sys.argv[1:] for name, f, old, new, tests in muts: if only and not any(o in name for o in only): continue p = os.path.join(M, f); src = open(p).read() n = src.count(old) if n != 1: print(f'BAD-ANCHOR ({n}) {name}', flush=True); continue open(p, 'w').write(src.replace(old, new)) # Its own process group, so a hung test's children die with it (a mutant that loops forever # never fails on its own; a hang is a kill). proc = subprocess.Popen(['node', '--test', *tests], cwd=M, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, start_new_session=True) try: out, _ = proc.communicate(timeout=300) except subprocess.TimeoutExpired: os.killpg(proc.pid, signal.SIGKILL); proc.communicate() out = None finally: open(p, 'w').write(src) if out is None: print(f'killed {name} (hung: killed after 300 s)', flush=True); continue fails = [l for l in out.splitlines() if l.startswith('not ok')] print(('killed ' if proc.returncode else 'SURVIVED') + f' {name}' + (f' ({len(fails)} failing)' if fails else ''), flush=True)