Files
MetonaSqlark/scripts/mutation-b6.py
T
thzxx 0b44620721 fix(v0.8.0): 全量回归审查 —— 1 处 P0 数据丢失 + 4 处 P1 + 9 处 P2 根因修复
方法:四个对抗性子代理分头审查(数据正确性 / 文档宣称 vs 实现 / 公共 API 契约 /
测试质量),每条结论要求可复现证据;逐条复核 + 探针确认 + 变异验证(40 项全部
被对应用例拦住)。

P0:事务活跃期间 repair()/close()/周期 checkpoint 推进 WAL 水位 → 已 COMMIT 的
事务整批消失且恢复报告"干净"。根因 hasPendingFlushData()/computeDurableLsn()
不看 txnSnapshot;守卫此前只在 CheckpointManager 两个回调里。修复:守卫下沉到
computeDurableLsn() 与 advanceWalCheckpoint() 入口(唯一实现)。

P1:
- WAL 前缀缺失丢弃整段活分片(回退上一代 manifest 时 kept 为空)→ 前缀缺失单独
  记录,后缀照常重放;仅 fromLsn === 0 时才算真异常
- 孤儿回收门槛只看引擎层 dataLossSuspected,漏掉 LSM 层被丢的 SSTable →
  统一 describeRecoveryDamage() 聚合判定(损坏时绝不删"引用不到"的文件)
- vacuum() 逐层压缩绕过维护链 → vacuumLevels() 每层作为维护链任务执行
- reclaimRetiredNow() 无视在途读者(读者把"已退休"读成"文件损坏")→ 有读者时
  退化为延迟回收

P2:WAL 记录级 CRC 损坏不计数不上报;旧格式表结构记录形状损坏静默当空库;
bloomFilterBitsPerKey 配置被接受却完全不生效(构建器写死默认值,实现缺陷);
幽灵 meta;介质读故障等于文件损坏的语义无用例;manifest 回读校验两条守卫无用例;
文件名≠载荷世代判定无用例;pageIdWatermark 单调性无用例;分片号两条真实不变量
无用例。

覆盖率口径(第二处漏洞):interface.ts 混着三个运行时函数(cloneRow 等)却被
描述为"纯类型、不纳入统计" → 实现搬到 src/engine/row_clone.ts;搬完门禁真的
失败(functions 93.84% < 94%),补测退化路径后通过。

测试质量:3 条空壳用例改值级断言;1 条"全损坏"用例实际只走缓存 → 拆成两条真
用例;5 秒墙钟 race 改门控 + 失败上限;setTimeout 改 whenIdle();<= 收紧为 <。

变异脚本加固:正控(干净基线必须全绿)、编译失败/0 用例单独归类、300s 超时、
逐字节 sha256 恢复校验、O_EXCL 进程锁、锚点唯一性;变异 22 → 40 项。

文档两轮订正(16 + 11 条不成立宣称):MVCC 快照隔离、backup 一致性快照、
"空洞检测截断"、体积(251,109 B / gzip 63,145 B)、测试与覆盖率数字、
"5 种存储引擎"、Tree-shakable、错误码表补 16 个码、恢复报告字段、已知限制
(回退单向 / 多实例依赖 Web Locks / manifest 体积 / 尾部 WAL 分片不可识别)。

验证:常规套件 92 套件 / 1980 用例全绿;覆盖率 90.59 / 82.59 / 94.14 / 93.50
(阈值 90/82/94/93);e2e 14/14(真实 Chromium + OPFS + CDP 崩溃);
重型套件 4 套件 / 27 用例;变异 40/40;lint + 两份 tsc 干净;dist 已重建。
2026-09-15 16:33:40 +08:00

600 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""B-6 变异验证:把每个修复回退到修复前的行为,对应用例必须失败。
用法:python3 scripts/mutation-b6.py
任何一条"回退后测试仍然通过"都会以非零退出码报出来(说明测试只是陪跑)。
"""
import hashlib
import io
import os
import re
import shlex
import signal
import subprocess
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
LSM = 'src/engine/aria/index/lsm.ts'
MANIFEST = 'src/engine/aria/store/manifest.ts'
MERGE = 'src/engine/aria/index/merge_iterator.ts'
ENGINE = 'src/engine/aria/index.ts'
SEGSTORE = 'src/engine/aria/wal/segmented_store.ts'
CHECKPOINT = 'src/engine/aria/wal/checkpoint.ts'
SSTABLE = 'src/engine/aria/index/sstable.ts'
B6 = 'tests/v080-b6-single-commit-point.test.ts'
WALSEG = 'tests/engine/aria-wal-segment.test.ts'
STRESS = 'tests/engine/aria-repair-hardening.test.ts'
WALLOG = 'src/engine/aria/wal/log.ts'
# 单条变异的 jest 运行上限(秒)。超时视为"变异脚本自身的问题",必须报错而不是
# 悄悄当成"测试通过了"。
JEST_TIMEOUT_S = 300
# 正控(未变异的干净代码)必须跑通的套件:没有正控就无法区分
# "变异被测试拦住"与"这套件本来就是红的"。
CONTROL_SUITES = [B6, WALSEG]
MUTATIONS = [
dict(
name='47 MergeIterator 立即补充(提前终止多算 1 条)',
file=MERGE,
old=""" // 胜出来源的补充推迟到下一次 next()(消费者只取 N 条 → 底层只产出 N 条)
this.pendingRefill = first.sourceIndex;""",
new=""" // [MUTATION] 立即补充(修复前行为)
this.seedFromSource(first.sourceIndex);""",
test=B6, pattern='流式扫描提前终止不多算',
),
dict(
name='49 compacting 单 boolean(跨层触发被丢弃)',
file=LSM,
old=""" private scheduleCompact(level: number): void {
if (level > MAX_LSM_LEVELS - 1 || this.compacting.has(level)) return;
this.compacting.add(level);""",
new=""" private scheduleCompact(level: number): void {
if (level > MAX_LSM_LEVELS - 1 || this.compacting.size > 0) return; // [MUTATION] 单 boolean 语义
this.compacting.add(level);""",
test=B6, pattern='按层 compaction 状态',
),
dict(
name='50 compaction 先 splice 整层(窗口内该层不可见)',
file=LSM,
old=""" const selected = [...this.levels[level]];
const selectedIds = new Set(selected.map((m) => m.id));""",
new=""" const selected = this.levels[level].splice(0, this.levels[level].length); // [MUTATION] 旧行为
const selectedIds = new Set(selected.map((m) => m.id));""",
test=B6, pattern='compaction 期间该层对读者始终可见',
),
dict(
name='51 底部层不回收墓碑',
file=LSM,
old=""" const merged = isBottomLevel
? mergedRaw.filter(([, value]) => !(value as unknown as Record<string, unknown>).__tombstone)
: mergedRaw;""",
new=""" const merged = mergedRaw; // [MUTATION] 不回收墓碑""",
test=B6, pattern='底部层合并回收墓碑',
),
dict(
name='44 冻结表失败后不重试',
file=LSM,
old=""" private async retryPendingFlushes(): Promise<void> {
let rounds = 0;""",
new=""" private async retryPendingFlushes(): Promise<void> {
return; // [MUTATION] 无重试路径
let rounds = 0;""",
test=B6, pattern='持续失败 → flush 明确报错且数据不丢',
),
dict(
name='45 后台错误检查放回"入链之前"',
file=LSM,
old=""" async flush(): Promise<void> {
this.enqueuePendingMemtables();
const reported = this.consumeBackgroundError();
await this.drainChain();""",
new=""" async flush(): Promise<void> {
// [MUTATION] 旧行为:先检查/报告后台错误 → 本次 flush 被整个跳过
const early = this.consumeBackgroundError();
if (early !== null) {
throw new DatabaseError('background error (legacy behaviour)', 'ARIA_BACKGROUND_ERROR', early);
}
this.enqueuePendingMemtables();
const reported = this.consumeBackgroundError();
await this.drainChain();""",
test=B6, pattern='后台错误不得让本次 flush 白做',
),
dict(
name='manifest 全世代损坏 → 返回空状态(静默空库)',
file=MANIFEST,
old=""" if (generations.length > 0) {
// 文件存在但一代都读不出来:这是元数据损坏,不是空库
throw new DatabaseError(""",
new=""" if (false && generations.length > 0) { // [MUTATION] 静默当空库
throw new DatabaseError(""",
test=B6, pattern='全部世代都损坏',
),
dict(
name='陈旧实例不设防(静默覆盖新世代)',
file=MANIFEST,
old=""" if (!opts.allowTakeover && newestForeign > this.generation) {""",
new=""" if (false && !opts.allowTakeover && newestForeign > this.generation) { // [MUTATION] 不设防""",
test=B6, pattern='陈旧实例提交被拒绝',
),
dict(
name='旧格式 meta 损坏 → 返回空(静默空库)',
file=ENGINE,
old=""" } catch (error) {
throw new DatabaseError(
`Legacy SSTable metadata "${key}" is corrupt and cannot be migrated: ${(error as Error).message}`,
'ARIA_LEGACY_META_CORRUPT',
error,
);
}""",
new=""" } catch (error) {
continue; // [MUTATION] 静默跳过损坏的旧 meta
}""",
test=B6, pattern='旧格式 meta 损坏',
),
dict(
name='介质读故障被当成"文件不存在"(误删 meta',
file=LSM,
old=""" } catch (error) {
throw new DatabaseError(
`AriaEngine failed to read SSTable id=${meta.id} from storage: ${(error as Error).message}`,
'ARIA_SSTABLE_READ_FAILED',
error,
);
}""",
new=""" } catch (error) {
await this.dropInvalidSSTable(meta, 'read failure treated as missing'); // [MUTATION] 旧行为
return null;
}""",
test=B6, pattern='介质读故障 ≠ 文件缺失',
),
dict(
name='WAL 分片空洞静默丢弃尾部',
file='src/engine/aria/wal/log.ts',
old=""" if (gaps.length > 0 && !opts.allowGaps) {""",
new=""" if (false && gaps.length > 0 && !opts.allowGaps) { // [MUTATION] 静默丢弃""",
test=B6, pattern='活跃区间内缺失分片',
),
dict(
name='WAL 分片号复用(旧世代排在新记录之后)',
file=SEGSTORE,
old=""" if (leftover.length === 0) {
this.currentSegment = Math.max(maxSeq + 1, keepFrom);""",
new=""" if (leftover.length === 0) {
this.currentSegment = 0; // [MUTATION] 复用分片号""",
test=STRESS, pattern='随机操作压力',
),
dict(
name='冻结表意图不做"写入是否真的丢了"校验',
file=ENGINE,
old=""" const summary = intents.map((i) => `${i.ns}#${i.id}(${i.entryCount} 项)`).join(', ');
throw new DatabaseError(""",
new=""" const summary = intents.map((i) => `${i.ns}#${i.id}(${i.entryCount} 项)`).join(', ');
if (summary) return; // [MUTATION] 不校验
throw new DatabaseError(""",
test=B6, pattern='ARIA_WRITE_LOST',
),
dict(
name='提交中的冻结表仍写进意图(回退时误报 WRITE_LOST',
file=LSM,
old=""" // 正在提交的表由当前这次 manifest 提交负责(见 FrozenTable.committing
if (frozen.committing) continue;""",
new=""" // [MUTATION] 提交中的表也写进意图""",
test=B6, pattern='回退上一代并标记 manifestFallback',
),
dict(
name='checkpoint 等完整 compaction(写路径被拖住)',
file=CHECKPOINT,
old=""" if (this.flushable && typeof this.flushable.flushMemtables === 'function') {""",
new=""" if (false && this.flushable && typeof this.flushable.flushMemtables === 'function') { // [MUTATION] 旧行为""",
test=B6, pattern='checkpoint 不再等完整 compaction',
),
dict(
name='collectReaders 不做结构版本重试(扫描漏掉并发发布的 SSTable)',
file=LSM,
old=""" if (version === this.structureVersion) return readers;
this.readStructureRetries++;
}""",
new=""" return readers; // [MUTATION] 不校验结构版本
}""",
test=B6, pattern='并发 flush 与 compaction 交错时',
),
dict(
name='点查不做结构版本重试(get 的校验失效)',
file=LSM,
old=""" if (version !== this.structureVersion) {
this.readStructureRetries++;
continue; // 期间有结构变化(新产物发布 / 前台冻结)→ 重来
}""",
new=""" if (false) {
this.readStructureRetries++;
continue;
}""",
test=B6, pattern='并发 flush 与 compaction 交错时',
),
dict(
name='冻结 memtable 不更新结构版本(点查读到旧值/漏数据)',
file=LSM,
old=""" // v0.8.0**前台变化也算结构变化**。读路径的"结构版本一致"必须覆盖""",
new=""" this.structureVersion--; // [MUTATION] 抵消下面的自增
// v0.8.0:**前台变化也算结构变化**。读路径的"结构版本一致"必须覆盖""",
test=B6, pattern='并发 flush 与 compaction 交错时',
),
dict(
name='sstable 越界策略不统一(scanAll 另有一套实现:越界即整体放弃)',
file=SSTABLE,
old=""" scanAll(callback: (key: string, value: Record<string, unknown>) => void): void {
for (const [key, value] of this.iterEntries(0, this.indexEntries.length - 1)) {
callback(key, value);
}
}""",
new=""" scanAll(callback: (key: string, value: Record<string, unknown>) => void): void {
// [MUTATION] 修复前的状态:这里另有一份解析循环,越界策略与 iterEntries 不同
const lenSize = this.lenFieldSize();
for (let bi = 0; bi < this.indexEntries.length; bi++) {
const blockData = this.getBlockData(this.indexEntries[bi]);
if (!blockData) continue;
const blockView = new DataView(blockData.buffer, blockData.byteOffset, blockData.byteLength);
const count = blockView.getUint32(0, false);
let offset = 4;
for (let i = 0; i < count; i++) {
if (offset + lenSize > blockData.byteLength) return;
const keyLen = blockView.getUint32(offset, false);
offset += lenSize;
if (offset + keyLen + lenSize > blockData.byteLength) return;
const key = new TextDecoder().decode(blockData.slice(offset, offset + keyLen));
offset += keyLen;
const valLen = blockView.getUint32(offset, false);
offset += lenSize;
if (offset + valLen > blockData.byteLength) return;
const valBytes = blockData.slice(offset, offset + valLen);
offset += valLen;
try { callback(key, JSON.parse(new TextDecoder().decode(valBytes))); } catch { /* skip */ }
}
}
}""",
test=B6, pattern='三个读取路径对同一个损坏文件给出一致结论',
),
dict(
name='vacuum 报告与事实无关的层数(硬编码 6)',
file=ENGINE,
old=""" const compactedLevels = await this.lsm.vacuumLevels();""",
new=""" await this.lsm.vacuumLevels();
const compactedLevels = 6; // [MUTATION] 旧行为:与事实无关的数字""",
test=B6, pattern='vacuum 返回',
),
dict(
name='close() 落盘失败后不释放后端/锁(无 try/finally',
file=ENGINE,
old=""" let failure: unknown = null;
try {
await this.flushAllLsms();""",
new=""" let failure: unknown = null;
if (true) { // [MUTATION] 旧行为:失败即中断,收尾逻辑不再执行
await this.flushAllLsms();
await this.bufferPool.flushAll();
await this.advanceWalCheckpoint();
await this.backend.close();
if (this.dbLock) { await this.dbLock.release(); this.dbLock = null; }
this.schemas.clear();
this.opened = false;
return;
}
try {
await this.flushAllLsms();""",
test=B6, pattern='close\\(\\) 在落盘失败时',
),
dict(
name='恢复报告不聚合 LSM 侧丢弃(数据丢失不可见)',
file=ENGINE,
old=""" for (const lsm of this.allLsms()) {
const r = lsm.getRecoveryReport();
for (const d of r.droppedSSTables) {
dropped.push({ namespace: r.namespace, ...d });
}
if (r.dataLossSuspected) dataLoss = true;
}""",
new=""" // [MUTATION] 不聚合 LSM 报告""",
test=B6, pattern='恢复报告聚合',
),
# ------------------------------------------------------------------
# review 轮(v0.8.0 全量回归审查)新增:每条对应一个已证实的缺陷
# ------------------------------------------------------------------
dict(
name='R1 事务进行中仍推进 WAL 水位(P0:已提交事务静默丢失)',
file=ENGINE,
old=""" if (this.currentTxnId !== null) return this.durableLsn;""",
new=""" if (false as boolean) return this.durableLsn; // [MUTATION] 去掉事务守卫""",
test=B6, pattern='事务进行中不得推进 WAL 水位',
),
dict(
name='R2 强制回收不理会途读者(在途读者数据被打断)',
file=LSM,
old=""" if (this.activeReaders.size > 0) {
this.reclaimRetired();
return;
}""",
new=""" if (false as boolean) {
this.reclaimRetired();
return;
}""",
test=B6, pattern='退休 SSTable 与在途读者',
),
dict(
name='R3 孤儿回收只看引擎层损坏(漏掉 LSM 层被丢的 SSTable',
file=ENGINE,
old=""" if (damage.length > 0) {""",
new=""" if (damage.length > 99) { // [MUTATION] 门槛失效""",
test=B6, pattern='回收门槛与孤儿回收',
),
dict(
name='R4 WAL 前缀缺失丢弃后缀分片(整段活 WAL 被丢掉)',
file=SEGSTORE,
old=""" if (fromLsn === 0 && missingPrefix.length > 0) {""",
new=""" if (missingPrefix.length > 0) { // [MUTATION] 前缀缺失一律算空洞""",
test=B6, pattern='WAL 前缀缺失',
),
dict(
name='R5 丢弃 SSTable 后不同步内存层数组(幽灵 meta)',
file=LSM,
old=""" if (this.levels[meta.level]?.some((m) => m.id === meta.id)) {""",
new=""" if (false as boolean) { // [MUTATION] 只从 manifest 摘除""",
test=B6, pattern='内存层数组与 manifest 必须一致',
),
dict(
name='R6 不校验"文件名世代 == 载荷世代"(改名的旧副本被当成新提交点)',
file=MANIFEST,
old=""" if (decoded.manifest.generation !== gen) {""",
new=""" if (false as boolean) { // [MUTATION] 不比对世代号""",
test=B6, pattern='文件名世代与载荷世代不一致',
),
dict(
name='R7 pageIdWatermark 不再取单调 max(水位可回退→页面 id 复用)',
file=ENGINE,
old=""" this.manifest.pageIdWatermark = Math.max(
this.manifest.pageIdWatermark,
this.fileManager.getNextPageId(),
);""",
new=""" this.manifest.pageIdWatermark = this.fileManager.getNextPageId(); // [MUTATION]""",
test=B6, pattern='水位是单调下限',
),
dict(
name='R8 提交不做回读校验(写丢了也报成功)',
file=MANIFEST,
old=""" if (!verified.ok || verified.manifest.generation !== nextGeneration) {""",
new=""" if (false as boolean) { // [MUTATION] 关掉回读校验""",
test=B6, pattern='读回来是坏的',
),
dict(
name='R9 WAL 损坏记录不计数(静默丢记录)',
file=WALLOG,
old=""" corrupt++;""",
new=""" corrupt += 0; // [MUTATION] 不计数""",
test=B6, pattern='WAL 记录级损坏',
),
dict(
name='R10 丢弃残缺文件不进恢复报告(静默丢弃)',
file=LSM,
old=""" this.recoveryReport.droppedSSTables.push({ id: meta.id, level: meta.level, reason });""",
new=""" void reason; // [MUTATION] 不记录丢弃原因""",
test=B6, pattern='文件真的残缺',
),
dict(
name='R11 介质读故障的错误码被改写(读故障≠文件损坏的语义丢失)',
file=LSM,
old=""" 'ARIA_SSTABLE_READ_FAILED',""",
new=""" 'MUTATED_READ_FAILED', // [MUTATION]""",
test=B6, pattern='介质读故障',
),
dict(
name='R12 冻结意图不再压低水位(水位越过只在内存+WAL 的写入)',
file=ENGINE,
old=""" return Math.min(this.durableLsn, Math.min(...intents.map((i) => i.lsnAtFreeze)));""",
new=""" return Math.max(this.durableLsn, Math.max(...intents.map((i) => i.lsnAtFreeze))); // [MUTATION]""",
test=B6, pattern='水位严格低于意图起点',
),
dict(
name='R13 truncateBefore 把分片号重置为 0(新记录落在 manifest 水位之下被过滤)',
file=SEGSTORE,
old=""" this.currentSegment = Math.max(maxSeq + 1, keepFrom);""",
new=""" this.currentSegment = 0; // [MUTATION] 修复前行为""",
test=WALSEG, pattern='写入永不落到 manifest 水位下限之下',
),
dict(
name='R14 truncate 把分片号重置为 0(旧世代与新记录同号)',
file=SEGSTORE,
old=""" this.currentSegment = Math.max(this.currentSegment, maxSeq + 1);""",
new=""" this.currentSegment = 0; // [MUTATION] 修复前行为""",
test=WALSEG, pattern='整体清空后分片号绝不回退',
),
dict(
name='R15 内部空洞不再上报(丢一段已提交事务却毫无痕迹)',
file=SEGSTORE,
old=""" gaps.push(missing);""",
new=""" void missing; // [MUTATION] 不上报空洞""",
test=WALSEG, pattern='内部空洞',
),
dict(
name='R17 bloom 位数被忽略(bloomFilterBitsPerKey 配置无效)',
file='src/engine/aria/index/sstable_builder.ts',
old=""" const bloomFilter = new BloomFilter(this.entries.length, this.bloomBitsPerKey);""",
new=""" const bloomFilter = new BloomFilter(this.entries.length); // [MUTATION] 忽略配置""",
test=B6, pattern='bloomFilterBitsPerKey',
),
dict(
name='R18 引擎层不把 bloom 配置透传给 LSM(配置无效)',
file=LSM,
old=""" this.bloomBitsPerKey = Number.isFinite(config.bloomBitsPerKey) && (config.bloomBitsPerKey as number) > 0
? Math.floor(config.bloomBitsPerKey as number)
: DEFAULT_BLOOM_BITS_PER_KEY;""",
new=""" this.bloomBitsPerKey = DEFAULT_BLOOM_BITS_PER_KEY; // [MUTATION] 忽略配置""",
test=B6, pattern='bloomFilterBitsPerKey',
),
dict(
name='R16 旧格式表结构形状坏掉时静默当空库',
file=ENGINE,
old=""" if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {""",
new=""" if (false as boolean) { // [MUTATION] 不校验形状""",
test=B6, pattern='旧格式表结构记录的形状校验',
),
]
CURRENT = {'path': None, 'content': None}
def _restore_current(*_args):
"""收到信号时也要把源码恢复原状(否则中断会留下变异源码)。"""
if CURRENT['path'] and CURRENT['content'] is not None:
io.open(CURRENT['path'], 'w', encoding='utf-8').write(CURRENT['content'])
print(f"\n[restore] {CURRENT['path']} 已恢复", flush=True)
sys.exit(130)
for _sig in ('SIGINT', 'SIGTERM', 'SIGHUP'):
try:
signal.signal(getattr(signal, _sig), _restore_current)
except (AttributeError, ValueError): # pragma: no cover - 平台差异
pass
def run(cmd, timeout=JEST_TIMEOUT_S):
"""跑一条命令:带超时(超时 = 变异脚本自身的问题,必须显式报出来)。"""
return subprocess.run(
cmd, cwd=ROOT, shell=True, capture_output=True, text=True, timeout=timeout,
)
def sha256(text):
return hashlib.sha256(text.encode('utf-8')).hexdigest()
def acquire_lock():
"""同一时刻只允许一个变异进程改 src/(并发跑会互相踩掉对方的源码)。"""
lock = os.path.join(ROOT, '.mutation-b6.lock')
try:
fd = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
except FileExistsError:
print(f'[abort] 已有变异进程在跑({lock} 存在)。')
print(' 确认没有残留后删除该文件再重试。')
sys.exit(2)
os.write(fd, str(os.getpid()).encode())
os.close(fd)
return lock
def classify(out):
"""把一次 jest 输出分类。顺序很重要:
编译失败/未匹配**不能**算"测试拦住了变异"。"""
if re.search(r'Test suite failed to run', out) or re.search(r'error TS\d+', out) \
or 'Cannot find module' in out or 'SyntaxError' in out:
return 'BAD(变异破坏编译)'
if re.search(r'^Tests:\s+0 total', out, re.M) or 'No tests found' in out:
return 'BAD(用例未匹配)'
if re.search(r'^Tests:.*\bfailed\b', out, re.M) or '✕' in out:
return 'OK(变异被拦住)'
return 'BAD(测试仍然通过)'
def positive_control():
"""正控:未变异的干净代码上,这些套件必须全绿。
没有正控的变异脚本会把"套件本来就红"误读成"变异被拦住了"。"""
print('[control] 干净代码基线检查 …', flush=True)
for suite in CONTROL_SUITES:
try:
res = run(f"npx jest {shlex.quote(suite)} --testPathIgnorePatterns='/node_modules/'")
except subprocess.TimeoutExpired:
print(f'[control] 超时:{suite}')
return False
out = (res.stdout or '') + (res.stderr or '')
m = re.search(r'^Tests:\s+(.*)$', out, re.M)
line = m.group(1) if m else '无法解析'
if 'failed' in line or not m:
print(f'[control] 基线不干净:{suite} → Tests: {line}')
return False
print(f'[control] ok {suite} → Tests: {line}', flush=True)
return True
def main():
lock = acquire_lock()
try:
return _main()
finally:
try:
os.unlink(lock)
except OSError: # pragma: no cover
pass
def _main():
only = sys.argv[1] if len(sys.argv) > 1 else None
if not positive_control():
print('\n[abort] 正控未通过 —— 先修好基线再谈"变异被拦住"。')
return 2
results = []
fingerprints = {}
for m in MUTATIONS:
if only and only not in m['name']:
continue
path = os.path.join(ROOT, m['file'])
original = io.open(path, encoding='utf-8').read()
fingerprints[path] = sha256(original)
if m['old'] not in original:
results.append((m['name'], 'SKIP(锚点未找到)'))
print(f"[skip] {m['name']}: 锚点未找到", flush=True)
continue
if original.count(m['old']) != 1:
results.append((m['name'], f"SKIP(锚点不唯一 x{original.count(m['old'])})"))
print(f"[skip] {m['name']}: 锚点出现 {original.count(m['old'])} 次,拒绝变异", flush=True)
continue
mutated = original.replace(m['old'], m['new'], 1)
CURRENT['path'] = path
CURRENT['content'] = original
io.open(path, 'w', encoding='utf-8').write(mutated)
try:
# 注意:用 shlex.quote 而不是 repr —— repr 会把正则里的反斜杠再转义一次,
# 于是 jest 收到 `\\(` 这种模式、匹配不到任何用例,脚本会把"没跑用例"
# 误判成"测试仍然通过"。
cmd = (f"npx jest {shlex.quote(m['test'])} -t {shlex.quote(m['pattern'])} "
f"--testPathIgnorePatterns='/node_modules/'")
try:
res = run(cmd)
out = (res.stdout or '') + (res.stderr or '')
except subprocess.TimeoutExpired:
status = 'BAD(超时)'
out = ''
else:
status = classify(out)
results.append((m['name'], status))
icon = 'ok ' if status == 'OK(变异被拦住)' else 'BAD'
print(f'[{icon}] {m["name"]}{status}', flush=True)
if status != 'OK(变异被拦住)':
tail = [l for l in out.splitlines() if l.startswith('Tests:')]
if tail:
print(f' {tail[0]}', flush=True)
finally:
io.open(path, 'w', encoding='utf-8').write(original)
# 恢复必须逐字节一致(否则会悄悄改动源码)
if sha256(io.open(path, encoding='utf-8').read()) != fingerprints[path]:
print(f'[FATAL] {m["file"]} 恢复后与原始内容不一致!', flush=True)
return 3
CURRENT['path'] = None
CURRENT['content'] = None
print('\n==== 变异验证汇总 ====')
bad = [r for r in results if r[1] != 'OK(变异被拦住)']
for name, status in results:
print(f' {status:20s} {name}')
if bad:
print(f'\n{len(bad)} 条变异没有被测试拦住 —— 那些用例只是陪跑。')
return 1
print(f'\n全部 {len(results)} 条变异都被对应用例拦住。')
return 0
if __name__ == '__main__':
sys.exit(main())