Five paths: tasks fields.mjs and fields.test.mjs, webui README, public/bus.js (aria-label only) and bus-browser.test.mjs. Phase B: 14 node suites and 9 scripts green, 8 of 8 mutants killed. The literal U+202E is now an escape. Co-Authored-By: Claude Opus 5.5 <[email protected]>
130 lines
5.3 KiB
Python
130 lines
5.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Row 88 (#1582) mutants, r1. Written in phase A; NOT RUN in phase A.
|
|
|
|
Run only after Sage sends "slot clear" and the suites pass:
|
|
python3 -I agents/researcher/work/queue-88/r1/mutants.py <worktree-root>
|
|
Optional: --only <id> runs one mutant after the baseline.
|
|
|
|
Each mutant is one exact text replacement that must match exactly once. The killer
|
|
command runs with the mutant in place. The file is restored in a finally block and
|
|
its bytes are checked against the original. Each run has its own process group, and
|
|
the group is killed on exit and on timeout. A timeout is reported as a gate failure,
|
|
never as a kill. Baseline: every killer must pass unmutated, or nothing runs.
|
|
"""
|
|
import hashlib
|
|
import os
|
|
import pathlib
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
|
|
WEBUI_BROWSER = ['node', '--test', 'tests/bus-browser.test.mjs']
|
|
TASKS_FIELDS = ['node', '--test', 'tests/fields.test.mjs']
|
|
TIMEOUT = 600
|
|
|
|
MUTANTS = [
|
|
dict(id='M1', file='packages/webui/src/public/bus.js',
|
|
old='aria-label="Who may write each field, scroll for all columns"',
|
|
new='aria-label="Who may write each field"',
|
|
cwd='packages/webui', argv=WEBUI_BROWSER, why='item 1: aria-label suffix'),
|
|
dict(id='M2', file='packages/tasks/src/fields.mjs',
|
|
old='if (!Object.hasOwn(table.verbs, w.verb))',
|
|
new='if (!(w.verb in table.verbs))',
|
|
cwd='packages/tasks', argv=TASKS_FIELDS, why='item 7: inherited verb key accepted'),
|
|
dict(id='M3', file='packages/webui/src/public/bus.js',
|
|
old='${txt(f.label)}${f.note',
|
|
new='${f.label}${f.note',
|
|
cwd='packages/webui', argv=WEBUI_BROWSER, why='item 8: label not escaped'),
|
|
dict(id='M3b', file='packages/webui/src/public/bus.js',
|
|
old='${txt(f.note)}</span>',
|
|
new='${f.note}</span>',
|
|
cwd='packages/webui', argv=WEBUI_BROWSER, why='item 8: note not escaped'),
|
|
dict(id='M4', file='docs/plans/2026-10-04_slice-1.md',
|
|
old='| due date | pm | `task.create`, `task.schedule` |',
|
|
new='| due date | pm | `task.create`, `task.assign` |',
|
|
cwd='packages/tasks', argv=TASKS_FIELDS, why='item 5: prose verb edit'),
|
|
dict(id='M5', file='packages/tasks/src/verbs.mjs',
|
|
old="if (PM_VERBS.has(verb) && mine.definition !== 'pm') fail('field-writer');",
|
|
new="if (PM_VERBS.has(verb) && mine.definition !== 'pm' && mine.definition !== 'cto') fail('field-writer');",
|
|
cwd='packages/tasks', argv=TASKS_FIELDS, why='item 6: cto passes the pm writer check'),
|
|
dict(id='M6', file='packages/webui/README.md',
|
|
old='names the file or the key at fault, never a path',
|
|
new='names the file and never a path',
|
|
cwd='packages/webui', argv=WEBUI_BROWSER, why='item 2: README wording reverted'),
|
|
dict(id='M9', file='packages/bus/src/broker.mjs',
|
|
old="const route = cls === 'cross-role' && proposedRoute === s.role ? 'human' : proposedRoute;",
|
|
new='const route = proposedRoute;',
|
|
cwd='packages/tasks', argv=TASKS_FIELDS, why='item 6: arbiter own decision not sent to human'),
|
|
]
|
|
|
|
|
|
def sha(data):
|
|
return hashlib.sha256(data).hexdigest()
|
|
|
|
|
|
def run(argv, cwd, timeout):
|
|
proc = subprocess.Popen(argv, cwd=cwd, start_new_session=True,
|
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
|
|
try:
|
|
out, _ = proc.communicate(timeout=timeout)
|
|
return proc.returncode, out.decode(errors='replace')
|
|
except subprocess.TimeoutExpired:
|
|
os.killpg(proc.pid, signal.SIGKILL)
|
|
out, _ = proc.communicate()
|
|
return 'timeout', out.decode(errors='replace')
|
|
finally:
|
|
try:
|
|
os.killpg(proc.pid, signal.SIGKILL)
|
|
except ProcessLookupError:
|
|
pass
|
|
|
|
|
|
def main():
|
|
root = pathlib.Path(sys.argv[1]).resolve()
|
|
only = sys.argv[3] if len(sys.argv) > 3 and sys.argv[2] == '--only' else None
|
|
seen = set()
|
|
for m in MUTANTS:
|
|
key = (m['cwd'], tuple(m['argv']))
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
rc, _ = run(m['argv'], root / m['cwd'], TIMEOUT)
|
|
if rc != 0:
|
|
print(f"BASELINE FAILED {m['cwd']} {' '.join(m['argv'])} rc={rc}; no mutant was run")
|
|
return 2
|
|
print(f"baseline ok {m['cwd']} {' '.join(m['argv'])}")
|
|
results = []
|
|
for m in MUTANTS:
|
|
if only and m['id'] != only:
|
|
continue
|
|
path = root / m['file']
|
|
orig = path.read_bytes()
|
|
text = orig.decode()
|
|
if text.count(m['old']) != 1:
|
|
results.append((m['id'], 'ANCHOR', f"old text matches {text.count(m['old'])} times"))
|
|
continue
|
|
path.write_bytes(text.replace(m['old'], m['new'], 1).encode())
|
|
try:
|
|
rc, _ = run(m['argv'], root / m['cwd'], TIMEOUT)
|
|
finally:
|
|
path.write_bytes(orig)
|
|
if sha(path.read_bytes()) != sha(orig):
|
|
print(f"RESTORE FAILED {m['file']}; stop and report")
|
|
return 3
|
|
if rc == 'timeout':
|
|
verdict = 'TIMEOUT (gate failure, not a kill)'
|
|
elif rc == 0:
|
|
verdict = 'SURVIVED'
|
|
else:
|
|
verdict = 'KILLED'
|
|
results.append((m['id'], verdict, m['why']))
|
|
print(f"{m['id']}: {verdict} ({m['why']})")
|
|
print('---')
|
|
for r in results:
|
|
print(*r)
|
|
return 0
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|