Files
thzxx c1c3036abd fix(site/v0.8.0): 站点版本同步 + 现场失败修复(file:// 打不开 OPFS 的可操作错误)
用户报告"站点演示失败了",实测复现并定位根因:

- 现象:直接双击 site/demo.html(file://)→ 点「🌲 Aria」→
  " 数据库初始化失败: Failed to open AriaEngine database "demo""(Memory 正常)。
- 根因:file:// 属不透明来源,Chromium 拒绝 navigator.storage.getDirectory()
  并抛 SecurityError;此时 isSecureContext 仍为 true、API 也存在,无法提前探测。
  引擎把它包成 ARIA_OPEN_ERROR 时丢掉了底层错误 → 消息对用户不可操作。
- 修复:OPFSBackend.open() 显式检查并抛 ARIA_OPFS_UNAVAILABLE,消息给出两条出路
  (用 http(s) 打开 / 改用 mode:'memory'),原始 SecurityError 挂 cause;
  site/demo.html 额外用中文说明"为什么失败 + 怎么修"。

顺带修掉一个更普遍的问题:DatabaseError 的第三个参数只进 details,err.cause
恒为 undefined,而文档/注释多处写"底层错误作为 cause 保留"。现在两者都成立
(details 语义不变;cause 声明为公开字段并接入标准错误链)。

站点版本同步:demo.html(title / 状态栏 / SQL 预置脚本 / console 日志)与
benchmark.html(title)此前仍是 v0.7.4(日志甚至是 v0.4.2)→ 统一 v0.8.0;
docs.html 的 AriaEngine 版本演进列表补上 v0.8.0 条目、错误码表补
ARIA_OPFS_UNAVAILABLE;README 补"OPFS 需要 http(s) 页面"的浏览器兼容说明。

回归与门禁:tests/engine/aria-opfs-unavailable.test.ts(5 项,含正常环境正控);
变异 R19 / R20 均被拦住(总计 42/42);93 套件 / 1985 用例;覆盖率
90.59 / 82.61 / 94.14 / 93.50(阈值 90/82/94/93);e2e 14/14;lint + 两份 tsc 干净;
dist 重建(251,731 B / gzip 63,431 B)并已同步全部体积宣称。
2026-09-15 17:09:29 +08:00

625 lines
27 KiB
Python
Raw Permalink 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='R20 DatabaseError 不再暴露标准 cause(根因丢失)',
file='src/constants.ts',
old=""" if (details !== undefined) {
(this as { cause?: unknown }).cause = details;
}""",
new=""" void details; // [MUTATION] 修复前:只有 details,没有 cause""",
test='tests/engine/aria-opfs-unavailable.test.ts', pattern='DatabaseError',
),
dict(
name='R19 OPFS 不可用时吞掉原因(错误不可操作)',
file='src/engine/aria/store/opfs_backend.ts',
old=""" throw new DatabaseError(
`OPFS is not accessible here (${(error as Error).name}: ${(error as Error).message}). ` +
'Chromium blocks OPFS for file:// pages — serve the page over http(s) ' +
"(e.g. `npx serve .` / `node tests/e2e/server.cjs`) or use mode: 'memory'.",
'ARIA_OPFS_UNAVAILABLE',
error,
);""",
new=""" throw new DatabaseError(
'Failed to open OPFS backend',
'ARIA_OPFS_UNAVAILABLE',
); // [MUTATION] 丢掉原始 cause 与建议""",
test='tests/engine/aria-opfs-unavailable.test.ts', pattern='OPFSBackend',
),
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())