用户报告"站点演示失败了",实测复现并定位根因: - 现象:直接双击 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)并已同步全部体积宣称。
625 lines
27 KiB
Python
625 lines
27 KiB
Python
#!/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())
|