Round 2 candidate on 26a0b0fab, manifest digest e3f9a400...8f70, 43
paths. It adds tests for Dewey's D1-D4 (comment 28752), the N-a project
socket timeout fix, and the title and N-f text fixes (sage-0492).
Rocko's run: 753 package tests passed, 59/59 mutants killed. Packet
inventory 4a2a9c57...683f verifies 85/85; scanned for secrets and
binaries, none found.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
121 lines
11 KiB
Python
121 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""One fault per acceptance check. Preflight is read-only; batches require Sage's slot."""
|
|
import json, os, pathlib, signal, subprocess, sys
|
|
ROOT = pathlib.Path('/mnt/storage/src/mosaic-stack-rocko-138-r4')
|
|
OUT = pathlib.Path(__file__).resolve().parent / 'mutants'
|
|
R = 'packages/project/src/read.mjs'
|
|
RT = 'packages/project/tests/read.test.mjs'
|
|
B = 'packages/bus/src/broker.mjs'
|
|
BT = 'packages/bus/tests/spec.test.mjs'
|
|
ST = 'packages/bus/tests/spec-socket.test.mjs'
|
|
HT = 'packages/harness/tests/spec-tools.test.mjs'
|
|
cases = []
|
|
def add(name, path, old, new, test):
|
|
cases.append(dict(check=name, path=path, old=old, new=new, test=test))
|
|
add('01-content-trailing-bytes', R, 'return text(await gitBytes(repo, [\'cat-file\', \'blob\', match[2]], MAX_BYTES));', 'return text(await gitBytes(repo, [\'cat-file\', \'blob\', match[2]], MAX_BYTES)).trimEnd();', RT)
|
|
add('02-file-commit-not-head', R, 'commit: log[0].commit, content', 'commit: head, content', RT)
|
|
add('03-historical-revision', R, 'const revision = commit === undefined ? head : commit;', 'const revision = head;', RT)
|
|
add('04-reachable-history', R, "await git(repo, ['merge-base', '--is-ancestor', revision, head]);", '/* reachability omitted */', RT)
|
|
add('05-full-commit-id', R, "if (typeof revision !== 'string' || !SHA.test(revision))", 'if (false)', RT)
|
|
add('06-read-path-confinement', R, r"if (typeof path !== 'string' || !(path === 'spec/north-star.md' || /^docs\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.md$/.test(path)))", 'if (false)', RT)
|
|
add('07-strict-utf8', R, '{ fatal: true, ignoreBOM: true }', '{ fatal: false, ignoreBOM: true }', RT)
|
|
add('08-no-nul', R, "if (value.includes('\\0'))", 'if (false)', RT)
|
|
add('09-blob-size', R, 'if (!Number.isSafeInteger(size) || size < 0 || size > MAX_BYTES)', 'if (false)', RT)
|
|
add('10-regular-blob-mode', R, '(100644|100755) blob', '(100644|100755|120000) blob', RT)
|
|
add('11-project-identity', R, 'metadata.business !== business.id', 'false', RT)
|
|
add('12-repository-validation', R, ' await inspectGit(repo);', ' /* repository validation omitted */', RT)
|
|
add('13-log-page-bound', R, 'log: log.slice(0, 100)', 'log: log.slice(0, 101)', RT)
|
|
add('14-log-truncation', R, 'truncated: log.length > 100', 'truncated: false', RT)
|
|
add('15-human-confirmation', R, 'result.author.name === args.business.human', 'true', RT)
|
|
add('16-preserve-bom', R, 'ignoreBOM: true', 'ignoreBOM: false', RT)
|
|
add('17-read-argument-keys', R, "if (!args || typeof args !== 'object' || Array.isArray(args) || Object.keys(args).some(k => !allowed.includes(k)))", 'if (false)', RT)
|
|
add('18-invalid-revision-refusal-code', R, "fail('project-read-commit', 'commit is not reachable from HEAD')", "fail('project-git', 'commit is not reachable from HEAD')", RT)
|
|
add('19-stale-citation', B, "if (args.spec_commit !== spec.commit) fail('stale-spec');", '/* stale citation check omitted */', BT)
|
|
add('20-unconfirmed-citation', B, "if (!spec.confirmed) fail('spec-unconfirmed');", '/* human confirmation omitted */', BT)
|
|
add('21-partial-citation', B, "if (typeof args.project !== 'string' || args.spec_path !== 'spec/north-star.md' ||\n typeof args.spec_commit !== 'string' || !/^[0-9a-f]{40}$/.test(args.spec_commit))", 'if (false)', BT)
|
|
add('22-persist-spec-commit', B, ' spec?.commit ?? null,', ' null,', BT)
|
|
add('23-persist-spec-path', B, ' spec?.path ?? null,', ' null,', BT)
|
|
add('24-uncited-raises-still-valid', B, "if ((a.spec_path !== undefined || a.spec_commit !== undefined) && !spec)", 'if (!spec)', BT)
|
|
add('25-session-author', B, 'definition: business.roles[s.role].definition, instance: s.role', 'definition: business.roles[s.role].definition, instance: business.human', BT)
|
|
add('26-record-spec-refusal', 'packages/project/src/index.mjs', "if (path === 'spec' || path.startsWith('spec/'))", 'if (false)', BT)
|
|
add('27-writer-cannot-accept-author', B, "keys(args, ['project', 'path', 'record', 'decision'], ['project', 'path', 'record']);", "keys(args, ['project', 'path', 'record', 'decision', 'author'], ['project', 'path', 'record']);", BT)
|
|
add('28-write-authority-before-effect', B, "this.#store.transaction(() => this.#checkAuthority(s, 'record.write', authority));", '/* pre-write authority omitted */', BT)
|
|
add('29-write-commit-receipt', B, '{ project: args.project, path: result.path, commit: result.commit }', '{ project: args.project, path: result.path, commit: null }', BT)
|
|
p = 'packages/bus/src/server.mjs'
|
|
old = "if (r.verb === 'spec.read' || r.verb === 'record.write' ||\n (r.verb === 'decision.raise' && (Object.hasOwn(r.args ?? {}, 'spec_path') || Object.hasOwn(r.args ?? {}, 'spec_commit'))))"
|
|
add('30-await-only-project-effects', p, old, 'if (false)', ST)
|
|
add('31-async-own-refusal-code', p, "e => { if (!socket.destroyed) finish({ ok: false, error: e instanceof BusError ? e.code : 'invalid-envelope' }); }", "e => { if (!socket.destroyed) finish({ ok: false, error: 'invalid-envelope' }); }", ST)
|
|
add('32-live-transient-proof', p, "Promise.resolve().then(() => broker.request(cap, { verb: r.verb, args: r.args ?? {} })).then(", "if (own) broker.disconnect(own);\n Promise.resolve().then(() => broker.request(cap, { verb: r.verb, args: r.args ?? {} })).then(", ST)
|
|
add('33-concurrent-write-refusal', 'packages/project/src/index.mjs', "fd = openSync(lock, 'wx', 0o600);", "fd = openSync(lock, 'w', 0o600);", ST)
|
|
p = 'packages/harness/src/tools.mjs'
|
|
add('34-read-for-every-role', p, ' add("read_spec",', ' if (held.has("record.write")) add("read_spec",', HT)
|
|
add('35-writer-follows-grant', p, 'if (held.has("record.write"))', 'if (true)', HT)
|
|
add('36-typed-spec-path', p, 'const: "spec/north-star.md"', 'const: "docs/design.md"', HT)
|
|
add('37-typed-writer-path', p, 'pattern: "^(docs/[A-Za-z0-9][A-Za-z0-9._-]{0,127}\\\\.md|(requirements|decisions)/[A-Za-z0-9][A-Za-z0-9._-]{0,127}\\\\.json)$"', 'pattern: ".*"', HT)
|
|
add('38-migration-runs-new-step', 'packages/bus/src/store.mjs', ' this.#db.exec(step.sql);', " if (step.from !== '3e') this.#db.exec(step.sql);", 'packages/bus/tests/spec-migration.test.mjs')
|
|
add('39-preserve-old-evidence', 'packages/bus/src/store.mjs', ' step.initialize?.(this);', " step.initialize?.(this);\n if (step.from === '3e') this.#db.exec(\"DROP TRIGGER decision_events_no_delete; DELETE FROM decision_events; CREATE TRIGGER decision_events_no_delete BEFORE DELETE ON decision_events BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END\");", 'packages/bus/tests/spec-migration.test.mjs')
|
|
add('40-future-step-chaining', 'packages/bus/src/store.mjs', 'while (version !== SCHEMA_VERSION)', 'if (version !== SCHEMA_VERSION)', 'packages/bus/tests/comment-migration.test.mjs')
|
|
roles = ['assistant', 'chief-executive-officer', 'chief-financial-officer', 'chief-information-officer',
|
|
'chief-information-security-officer', 'chief-marketing-officer', 'chief-of-staff', 'coder', 'cto', 'pm', 'reviewer']
|
|
for i, role in enumerate(roles, 41):
|
|
add(f'{i:02}-record-grant-{role}', f'roles/{role}.json', ', "record.write"', '', 'packages/business/tests/record-grants.test.mjs')
|
|
add('52-no-adapter-refusal', B, "if (!this.#project) fail('project-not-configured');", "if (false) fail('project-not-configured');", 'packages/bus/tests/project-runtime.test.mjs')
|
|
add('53-runtime-passes-adapter', 'packages/bus/src/runtime.mjs', """ project: {
|
|
readSpec: context => projectModule.readSpec({ ...context, dataRoot }),
|
|
writeRecord: context => projectModule.writeRecord({ ...context, dataRoot }),
|
|
ProjectError: projectModule.ProjectError,
|
|
},
|
|
""", '', 'packages/bus/tests/project-runtime.test.mjs')
|
|
add('54-broker-import-closure', B, "import { randomUUID, randomBytes } from 'node:crypto';", "import { randomUUID, randomBytes } from 'node:crypto';\nimport { dirname } from 'node:path';", 'packages/bus/tests/project-runtime.test.mjs')
|
|
AB = 'packages/bus/tests/project-await.test.mjs'
|
|
READ = ("const result = await readSpec({ ...context, ...(args.commit === undefined ? {} : { commit: args.commit }) });\n"
|
|
" this.#store.transaction(() => this.#agent(s));\n"
|
|
" this.#secretCheck(result);\n")
|
|
add('D1-receipt-failure-reported', B, " } catch { fail('outcome-unknown'); }", " } catch {}", AB)
|
|
add('D2-read-recheck-agent', B, READ, READ.replace(" this.#store.transaction(() => this.#agent(s));\n", ''), AB)
|
|
add('D3-read-secret-check', B, READ, READ.replace(" this.#secretCheck(result);\n", ''), AB)
|
|
add('D4-gitbytes-bound', 'packages/project/src/git.mjs', 'if (size > maxBytes) { failed = true; kill(); }', 'if (false) { failed = true; kill(); }', RT)
|
|
add('N-a-project-socket-deadline', 'packages/bus/src/server.mjs', ' socket.setTimeout(120_000);', ' /* project deadline omitted */', ST)
|
|
|
|
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)
|
|
rows = []
|
|
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:
|
|
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', c['test']], cwd=ROOT,
|
|
env={**os.environ, 'DOCKER_HOST': 'unix:///nonexistent.sock'}, 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()
|
|
invalid = any(x in content for x in ['SyntaxError:', 'ERR_MODULE_NOT_FOUND'])
|
|
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']} | {'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)
|