Unclaimed files left in the canonical checkout. Inventory: canonical-cleanup-inventory.md (commit-as-evidence groups). detect-secrets scan clean on 2026-11-02: hits are sha256 pins, git object ids, test sentinels already in tracked tests and unit-file lines.
129 lines
13 KiB
Python
129 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""Read-only --preflight; running cases requires Sage's explicit test slot."""
|
|
import json, os, pathlib, signal, subprocess, sys, re
|
|
ROOT=pathlib.Path('/mnt/storage/src/mosaic-stack-rocko-129-r2')
|
|
OUT=pathlib.Path(__file__).resolve().parent/'mutants'
|
|
A='packages/webui/src/chat.mjs';H='packages/webui/src/chat-http.mjs';U='packages/webui/src/public/chat.js'
|
|
B='packages/webui/src/public/bus.js';C='packages/webui/src/cli.mjs';S='packages/webui/src/serve.mjs'
|
|
T='packages/webui/tests/chat.test.mjs';BT='packages/webui/tests/chat-browser.test.mjs'
|
|
cases=[]
|
|
def add(name,path,old,new,test,killer):cases.append(dict(check=name,path=path,old=old,new=new,test=test,killer=killer))
|
|
add('01-project-list',A,"projects: () => call('project.list', {}),","projects: () => call('thread.list', {}),",T,'chat reads map only fixed verbs')
|
|
add('02-thread-filter',A,"project === undefined ? {} : { project }","{}",T,'chat reads map only fixed verbs')
|
|
add('03-project-repo',A,"...(repo === undefined ? {} : { repo })","...{}",T,'chat project.create: one verified write')
|
|
add('04-thread-name',A,"call('thread.create', { project, name })","call('thread.create', { project })",T,'chat thread.create: one verified write')
|
|
add('05-request-class',A,"class: 'REQUEST'","class: 'INFO'",T,'chat thread.send: one verified write')
|
|
add('06-thread-target',A,"call('thread.send', { thread, body,","call('thread.send', { thread: 'wrong', body,",T,'chat thread.send: one verified write')
|
|
add('07-resume-verb',A,"call('thread.open', { thread })","call('thread.list', { thread })",T,'chat thread.open: one verified write')
|
|
add('08-owner-status',H,"'thread-not-owned': 403","'thread-not-owned': 404",T,'REQ-USER-2 real broker')
|
|
add('09-foreign-status',H,"'thread-not-found': 404","'thread-not-found': 403",T,'REQ-USER-2 real broker')
|
|
add('10-strict-query',H,"if ([...q.keys()].some(k => !allowed.includes(k) || q.getAll(k).length !== 1))","if (false)",T,'chat refuses malformed and repeated query/id values')
|
|
add('11-uuid',H,"if (!id(thread)) throw Error();","if (false) throw Error();",T,'chat refuses malformed and repeated query/id values')
|
|
add('12-page-limit',H,"if (q.has('limit') && (!/^[1-9]\\d*$/.test(q.get('limit')) || Number(q.get('limit')) > 200))","if (false)",T,'chat refuses malformed and repeated query/id values')
|
|
add('13-default-page',H,"Number(q.get('limit')) : 100","Number(q.get('limit')) : 50",T,'chat reads map only fixed verbs')
|
|
add('14-cursor',A,"...(after ? { after } : {})","...{}",T,'chat reads map only fixed verbs')
|
|
add('15-more-metadata',H,"hasMore: value.length === args.limit","hasMore: false",T,'chat reads map only fixed verbs')
|
|
add('16-read-array',H,"if (!Array.isArray(value)) return refuse(502, 'invalid-response');","/* array guard omitted */",T,'chat read failures redact ownership')
|
|
add('17-send-mode',H,"if (!send || !named || !preview)","if (!named || !preview)",T,'chat repeats every POST boundary')
|
|
add('18-named-host',H,"if (!send || !named || !preview)","if (!send || !preview)",T,'chat repeats every POST boundary')
|
|
add('19-single-identity',H,"if (one(req, preview.header) !== preview.user)","if (false)",T,'chat repeats every POST boundary')
|
|
add('20-origin',H,"if (one(req, 'origin') !== `https://${preview.host}`)","if (false)",T,'chat repeats every POST boundary')
|
|
add('21-fetch-metadata',H,"if (one(req, 'sec-fetch-site') !== 'same-origin')","if (false)",T,'chat repeats every POST boundary')
|
|
add('22-json-type',H,"if ((req.headers['content-type'] || '').split(';')[0].trim().toLowerCase() !== 'application/json')","if (false)",T,'chat project.create: each existing Console write fence')
|
|
add('23-json-byte-bound',H,"if (size > 16384) throw Error();","if (false) throw Error();",T,'chat thread.send: each existing Console write fence')
|
|
add('24-strict-shape',H,"Object.keys(value).every(k => allowed.includes(k))","true",T,'chat validates names, body, project')
|
|
add('25-name-length',H,"value.length > 0 && value.length <= 200","true",T,'chat validates names, body, project')
|
|
add('26-nonempty-body',H,"!v.body.trim()","false",T,'chat validates names, body, project')
|
|
add('27-verified-ack',H,"!id(value.id)","false",T,'chat project.create: refusal or lost/malformed receipt')
|
|
add('28-opened-ack',H,"value.opened !== thread","false",T,'chat thread.open: refusal or lost/malformed receipt')
|
|
add('29-no-retry',H,"if (write) line(`error=${code}`);","if (write) { await chat[action](args).catch(() => {}); line(`error=${code}`); }",T,'chat project.create: refusal or lost/malformed receipt')
|
|
add('30-uncertain-state',H,"return refuse(503, 'outcome-unknown', 'unknown');","return refuse(503, 'outcome-unknown', false);",T,'chat project.create: refusal or lost/malformed receipt')
|
|
add('31-private-errors',H,"const code = Object.hasOwn(STATUS, outcome.code) ? outcome.code : 'invalid-response';","const code = err.message;",T,'chat project.create: refusal or lost/malformed receipt')
|
|
add('32-dispatch',S,"if (path === '/api/chat' || path.startsWith('/api/chat/'))","if (false)",T,'chat reads map only fixed verbs')
|
|
add('33-cli-adapter',C," chat = createChat({ call });"," chat = null;",T,'configured Console process injects chat')
|
|
add('34-static-script',S,"'markdown-preview.js', 'chat.js'","'markdown-preview.js'",T,'chat assets, GET purity')
|
|
add('35-nav',B,"['/chat', 'Chat', '', 'inbox'], ","",BT,'chat browser: list/create named projects')
|
|
add('36-inert-bodies',U,"${txt(m.body)}","${m.body}",BT,'chat browser: thread drafts, receipts and cursor history')
|
|
add('37-thread-drafts',U,"const state = id => {","const state = id => { id = 'shared';",BT,'chat browser: thread drafts, receipts and cursor history')
|
|
add('38-submit-once',U,"if (s.pending) return;","/* pending check omitted */",BT,'chat browser: page forward and deduplicate replies')
|
|
add('39-browser-byte-bound',U,"new TextEncoder().encode(JSON.stringify(value)).length > 16384","false",BT,'chat browser: page forward and deduplicate replies')
|
|
add('40-manual-redirect',U,"cache: 'no-store', redirect: 'manual', signal,","cache: 'no-store', redirect: 'follow', signal,",BT,'chat browser: thread drafts, receipts and cursor history')
|
|
add('41-stale-read-refusal',U,"if (g !== generation || out() || err.name === 'AbortError' || err.code === 'signed-out') return;","if (out() || err.name === 'AbortError' || err.code === 'signed-out') return;",BT,'chat browser: a late read from A cannot paint B')
|
|
add('42-owner-cache',U,"states.delete(id); current.projects = [];","current.projects = [];",BT,'chat browser: uncertain writes retain draft')
|
|
add('43-bounded-display',U,".values()].slice(-200)",".values()]",BT,'chat browser: display stays bounded')
|
|
add('44-no-write-get',A,"projects: () => call('project.list', {}),","projects: async () => { await call('thread.open', {}); return call('project.list', {}); },",T,'chat reads map only fixed verbs')
|
|
add('45-inert-labels',U,"const txt = value => esc(inert(value));","const txt = value => String(value ?? '');",BT,'chat browser: list/create named projects')
|
|
add('46-thread-repo-ui',U,"...(f.repo ? { repo: f.repo } : {})","...{}",BT,'chat browser: list/create named projects')
|
|
# Scroll and receipt mutants are added after their red/green cases settle the final source sites.
|
|
|
|
add('47-abort-old-reads',U,"controller?.abort(); controller = new AbortController();","controller = new AbortController();",BT,'chat browser: a late read from A cannot paint B')
|
|
add('48-forget-private-cache',U,"states.clear(); forms.clear();","forms.clear();",BT,'chat browser: a refusal elsewhere forgets private chat drafts')
|
|
|
|
add('49-polling',B,"shown = route; schedule(); return;","shown = route; return;",BT,'chat browser: automatic polling follows a reply')
|
|
add('50-control-visibility',U,"const txt = value => esc(inert(value));","const txt = value => esc(value);",BT,'chat browser: list/create named projects')
|
|
add('51-safe-link-id',U,"UUID.test(id) ? `<a href=","true ? `<a href=",BT,'chat browser: list/create named projects')
|
|
add('52-name-controls',H,r"!/[\r\n\0]/.test(value)","true",T,'chat validates names, body, project')
|
|
add('53-after-id',H,"if (q.has('after') && !id(q.get('after'))) throw Error();","if (false) throw Error();",T,'chat refuses malformed and repeated query/id values')
|
|
add('54-readonly-controls',U,"!current.canSend || out() || pending ?","out() || pending ?",BT,'chat browser: uncertain writes retain draft')
|
|
add('55-ui-ack',U,"if (action === 'open' ? result.opened !== key : !UUID.test(result.id))","if (false)",BT,'chat browser: a confirmed visible message replaces its pending receipt')
|
|
|
|
add('56-explicit-tail',U,"navigated || s.tailRequested || s.follow","navigated || s.follow",BT,'chat browser: scrolled history stays put')
|
|
add('57-own-send-tail',U,"s.follow = true; s.tailRequested = true; s.receiptId = result.id;","s.follow = true; s.tailRequested = false; s.receiptId = result.id;",BT,'chat browser: scrolled history stays put')
|
|
add('58-shown-receipt',U,"if (s.receiptId && s.rows.some(m => m.id === s.receiptId))","if (false)",BT,'chat browser: a confirmed visible message replaces its pending receipt')
|
|
add('59-late-create',U,"if (location.hash === startedRoute) location.hash = '#/chat/'","location.hash = '#/chat/'",BT,'chat browser: a late project creation receipt')
|
|
add('60-static-style',S,"'shell.css', 'chat.css'","'shell.css'",T,'chat assets, GET purity')
|
|
add('61-cli-adapter-option',C,"projectFiles, projectFile, projectSave, chat });","projectFiles, projectFile, projectSave });",T,'configured Console process injects chat')
|
|
add('62-palette',B,"['#/chat', 'Chat'], ","",BT,'chat browser: list/create named projects')
|
|
add('63-caret',U,"if (!navigated && keep?.field)","if (false)",BT,'chat browser: thread drafts, receipts and cursor history')
|
|
add('64-readonly-submit',U,"if (!current || !current.canSend || out()) return;","if (!current || out()) return;",BT,'chat browser: uncertain writes retain draft')
|
|
|
|
add('65-send-post-path',U,"void write('send', id, { body: state(id).draft }, `threads/${id}/messages`);", "void write('send', id, { body: state(id).draft }, `threads/${id}/open`);",BT,'chat browser: thread drafts, receipts and cursor history')
|
|
add('66-instance-receipt',H,"(action === 'createThread' && (typeof value.instance !== 'string' || !KEY.test(value.instance)))", "false",T,'chat createThread receipt requires an instance even when its id is valid')
|
|
add('67-receipt-fields',H,"const receipt = action === 'open' ? { opened: thread } : { id: value.id };", "const receipt = { ...value };",T,'chat write receipts expose only the fields belonging to their operation')
|
|
add('68-bidi-controls',U,r"\u202a-\u202e\u2066-\u2069", "",BT,'chat browser: list/create named projects')
|
|
|
|
for c in cases:
|
|
assert (ROOT / c['path']).read_text().count(c['old']) == 1, c['check']
|
|
if '--preflight' in sys.argv:
|
|
print('PREFLIGHT_OK', len(cases)); sys.exit(0)
|
|
OUT.mkdir(exist_ok=True)
|
|
resume = next((int(a.split('=')[1]) for a in sys.argv if a.startswith('--resume=')), 1)
|
|
rows = json.loads((OUT / 'results.json').read_text())[:resume - 1] if resume > 1 else []
|
|
def stop_tree(pid):
|
|
# Git's own runner creates process groups too. Capture descendants before killing the parent.
|
|
descendants = {pid}
|
|
for _ in range(8):
|
|
before = set(descendants)
|
|
for entry in pathlib.Path('/proc').iterdir():
|
|
if not entry.name.isdigit(): continue
|
|
try:
|
|
fields = (entry / 'stat').read_text().split(') ', 1)[1].split()
|
|
if int(fields[1]) in descendants: descendants.add(int(entry.name))
|
|
except (OSError, ValueError, IndexError): pass
|
|
if before == descendants: break
|
|
for child in descendants:
|
|
try: os.killpg(child, signal.SIGKILL)
|
|
except ProcessLookupError: pass
|
|
for c in cases[resume - 1:]:
|
|
file = ROOT / c['path']; original = file.read_bytes(); process = None
|
|
try:
|
|
file.write_text(original.decode().replace(c['old'], c['new']))
|
|
with (OUT / (c['check'] + '.log')).open('w') as log:
|
|
process = subprocess.Popen(['node', '--test', '--test-name-pattern', re.escape(c['killer']), c['test']], cwd=ROOT,
|
|
env={**os.environ, 'DOCKER_HOST': 'unix:///nonexistent.sock', 'TMPDIR': '/mnt/storage/scratch/rocko-129-r2/tmp'}, stdout=log,
|
|
stderr=subprocess.STDOUT, start_new_session=True)
|
|
try: rc = process.wait(timeout=180)
|
|
except subprocess.TimeoutExpired: stop_tree(process.pid); process.wait(); rc = 'timeout'
|
|
content = (OUT / (c['check'] + '.log')).read_text()
|
|
checker = ['node', '--check', c['path']] if c['path'].endswith(('.mjs', '.js')) else ['bash', '-n', c['path']]
|
|
syntax = subprocess.run(checker, cwd=ROOT, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, start_new_session=True, timeout=10)
|
|
invalid = syntax.returncode != 0 or 'ERR_MODULE_NOT_FOUND' in content
|
|
verdict = 'INVALID' if invalid else 'TIMEOUT' if rc == 'timeout' else 'SURVIVED' if rc == 0 else 'KILLED'
|
|
rows.append({k: c[k] for k in ['check', 'path', 'test', 'killer']} | {'result': verdict, 'exit': rc})
|
|
(OUT / 'results.json').write_text(json.dumps(rows, indent=2) + '\n')
|
|
print(c['check'], verdict, rc, flush=True)
|
|
finally:
|
|
if process is not None and process.poll() is None: stop_tree(process.pid); process.wait()
|
|
file.write_bytes(original)
|
|
if verdict != 'KILLED': sys.exit(1)
|