"""nextest 计数锚换算器 (v0.98.3 M1 S2, openspec/changes/v0983-test-infra/design.md §3.2)
锚口径 (ar 裁定 09-02): nextest 计数 + `cargo test --doc` doctest 计数合并
—— nextest 不跑 doctest, 锚三元组 (passed/failed/ignored) 与 doctest 计数分列。
对账纪律 (claude-test O-1 / ar 00:23 硬要求):
对账单位 = 测试名多重集合 (Counter), 禁用聚合计数互推——nextest 汇总行的
skipped 混合"过滤不匹配"与"真 ignore", 与 cargo 侧 ignored 字段口径不同源,
汇总对汇总必错。diff 逐测试名报告。
用法:
--self-test 构造样本流自测解析与对账逻辑 (CI/门禁可调用)
--from-files N.json C.txt D.txt
从落盘输出解析 (nextest libtest-json / cargo test 输出 /
cargo test --doc 输出), 供复算与留痕
--compare-vs-cargo 本机实测: 依次跑 nextest 全量(json) + cargo test --tests
+ cargo test --doc, 解析并集合级对账, 不一致即退出码 1
(O-2: workspace 全量执行, 编译段墙钟与执行段时间分开报告)
"""
from __future__ import annotations
import argparse
import collections
import json
import re
import subprocess
import sys
import time
from dataclasses import dataclass, field
_NEXTTEST_NAME = re.compile(r"\$(.+)$")
def _norm_nextest_name(name: str) -> str:
m = _NEXTTEST_NAME.search(name)
return m.group(1) if m else name
def parse_nextest_json(stream: str) -> tuple[collections.Counter, dict]:
"""解析 nextest --message-format libtest-json 事件流。
返回 (Counter{test_path: count}, meta);meta 含 exec_time 总和与
top 10 最耗时测试 (W1 遗留: 单测时长实证供数)。
event 取值: started/ok/failed/ignored (skipped= 过滤不匹配, 不计入锚)。
"""
counter: collections.Counter = collections.Counter()
failed: collections.Counter = collections.Counter()
ignored: collections.Counter = collections.Counter()
started: set[str] = set()
exec_times: list[tuple[float, str]] = []
non_json_lines = 0
for line in stream.splitlines():
line = line.strip()
if not line or not line.startswith("{"):
if line:
non_json_lines += 1
continue
try:
e = json.loads(line)
except json.JSONDecodeError:
non_json_lines += 1
continue
if e.get("type") != "test":
continue
name = _norm_nextest_name(e.get("name", ""))
ev = e.get("event")
if ev == "started":
started.add(name)
elif ev == "ok":
counter[name] += 1
if "exec_time" in e:
exec_times.append((float(e["exec_time"]), name))
elif ev == "failed":
failed[name] += 1
elif ev == "ignored":
ignored[name] += 1
exec_times.sort(reverse=True)
finals = counter + failed + ignored
missing_final = started - set(finals.elements())
meta = {
"passed": sum(counter.values()),
"failed": sum(failed.values()),
"ignored": sum(ignored.values()),
"started_total": len(started),
"final_total": sum(finals.values()),
"missing_final": sorted(missing_final),
"ignored_counter": dict(ignored),
"non_json_lines": non_json_lines,
"exec_total": round(sum(t for t, _ in exec_times), 3),
"top_slow": [(round(t, 3), n) for t, n in exec_times[:10]],
}
return counter, meta
_NEXTTEST_FAILED = re.compile(r'\{"type":"test","event":"failed","name":"([^"]+)"')
def _nextest_failed_names(stream: str) -> list[str]:
"""红测名单 (规范化名, 排序去重)——对账报告点名用。"""
return sorted({_norm_nextest_name(m) for m in _NEXTTEST_FAILED.findall(stream)})
def _strip_ansi(s: str) -> str:
"""剥离 ANSI 转义序列 (手写状态机, 零正则转义歧义)。
背景 (S2 首对账实证): TUI 测试进程的终端查询序列 (OSC 11 背景色探测
ESC ]11;? ESC \) 粘连在 "test NAME ... ok" 行前, 破坏行首锚定 →
only_a 假差异 10 项。覆盖: CSI (ESC[...最终字节@-~) / OSC (...BEL 或
ST=ESC\) / 其他 Fe 单字节序列。
"""
ESC, BEL = chr(27), chr(7)
out: list[str] = []
i, n = 0, len(s)
while i < n:
c = s[i]
if c != ESC:
out.append(c)
i += 1
continue
if i + 1 < n and s[i + 1] == "[":
j = i + 2
while j < n and not ("@" <= s[j] <= "~"):
j += 1
i = j + 1
elif i + 1 < n and s[i + 1] == "]":
j = i + 2
while j < n:
if s[j] == BEL:
j += 1
break
if s[j] == ESC and j + 1 < n and s[j + 1] == chr(92):
j += 2
break
j += 1
i = j
else:
i += 2
return "".join(out)
_CARGO_TEST_LINE = re.compile(r"^test (\S+?)(?: - should panic)? \.\.\. (ok|FAILED|ignored)")
def parse_cargo_test(stream: str) -> tuple[collections.Counter, collections.Counter, collections.Counter]:
"""解析 cargo test (human 格式) 输出 → (passed, failed, ignored) 多重集合。
多 target 同名测试自然累加计数 (multiset 语义, 与 nextest 侧规范化后对齐)。
"""
passed: collections.Counter = collections.Counter()
failed: collections.Counter = collections.Counter()
ignored: collections.Counter = collections.Counter()
for line in _strip_ansi(stream).splitlines():
m = _CARGO_TEST_LINE.match(line.strip())
if not m:
continue
name, status = m.group(1), m.group(2)
(passed if status == "ok" else failed if status == "FAILED" else ignored)[name] += 1
return passed, failed, ignored
_CARGO_DOC_LINE = re.compile(r"^test (\S+ - .+?) \.\.\. (ok|FAILED|ignored)")
def parse_cargo_doctest(stream: str) -> tuple[collections.Counter, collections.Counter, collections.Counter]:
"""解析 cargo test --doc 输出 → (passed, failed, ignored)。
doctest 名形如 "src/lib.rs - ident (line N)",用独立匹配器避免与
普通测试行混淆 (doctest 名含 " - ", 普通测试名不含)。
"""
passed: collections.Counter = collections.Counter()
failed: collections.Counter = collections.Counter()
ignored: collections.Counter = collections.Counter()
for line in _strip_ansi(stream).splitlines():
m = _CARGO_DOC_LINE.match(line.strip())
if not m:
continue
name, status = m.group(1), m.group(2)
(passed if status == "ok" else failed if status == "FAILED" else ignored)[name] += 1
return passed, failed, ignored
def diff_counters(a: dict, b: dict) -> dict:
"""多重集合 diff: a 有 b 无 / b 有 a 无 / 两侧都有但计数不等。
宽容 dict 入参 (内部转 Counter); self-test 直接传 dict 字面量。
"""
a, b = collections.Counter(a), collections.Counter(b)
only_a = {n: a[n] for n in a if n not in b}
only_b = {n: b[n] for n in b if n not in a}
count_mismatch = {
n: {"a": a[n], "b": b[n]} for n in a.keys() & b.keys() if a[n] != b[n]
}
return {
"equal": not (only_a or only_b or count_mismatch),
"only_a": dict(only_a),
"only_b": dict(only_b),
"count_mismatch": count_mismatch,
}
def reconcile(
n_counter: collections.Counter,
n_meta: dict,
c_pass: collections.Counter,
c_ign: collections.Counter,
d_pass: collections.Counter,
d_fail: collections.Counter,
d_ign: collections.Counter,
human_log_hint: str = "",
) -> tuple[int, list[str]]:
"""共享对账核心 (claude-test 05:35 修复规格): compare_vs_cargo 与 from_files
两路径必须同走同一套断言——证据重放与现场对账等价。
断言序 (ar 采信规格):
1. 捕获卫生: nextest JSON 流混入非 JSON 行 → FAIL (旧缺陷根因)
2. 完整性精化: missing_final 与 cargo ignored 集比对——全部可解释
(静态 #[ignore]) → WARN+以 cargo 口径为准; 不可解释缺名 → FAIL
3. passed/ignored 名级 multiset 双向对账——passed 差异 FAIL;
ignored 差异若全部由 missing_final 解释 → WARN(cargo 口径),
否则 FAIL(交叉不一致)
返回 (exit_rc, report_lines)。rc: 0=一致 1=对账差异 2=断言 FAIL。
"""
lines: list[str] = []
rc = 0
if n_meta.get("non_json_lines", 0) > 0:
rc = 2
lines.append(
f"[FAIL] 捕获卫生: nextest JSON 流混入 {n_meta['non_json_lines']} 条"
f"非 JSON 行{human_log_hint}——stdout 与 stderr 必须分离落盘"
)
missing = set(n_meta.get("missing_final", []))
if missing:
explicable = missing & set(c_ign)
unexplained = missing - set(c_ign)
if unexplained:
rc = max(rc, 2)
lines.append(
f"[FAIL] 完整性: {len(unexplained)} 个 started 无 final 且不在 "
f"cargo ignored 集(不可解释): {sorted(unexplained)[:10]}"
)
else:
lines.append(
f"[WARN] 完整性: {len(missing)} 个静态 #[ignore] 缺 final 事件"
f"(全部在 cargo ignored 集可解释)——ignored 锚以 cargo 口径为准"
)
d1 = diff_counters(n_counter, c_pass)
if not d1["equal"]:
rc = max(rc, 1)
lines += report_diff("passed 集合 (nextest vs cargo --tests, multiset)", d1)
n_ign = collections.Counter(n_meta.get("ignored_counter", {}))
d2 = diff_counters(n_ign, c_ign)
only_b_names = set(d2["only_b"])
only_a_names = set(d2["only_a"])
covered_by_missing = only_b_names <= missing
unexplained_ign = only_a_names or (only_b_names - missing) or d2["count_mismatch"]
if not d2["equal"]:
if covered_by_missing and not unexplained_ign:
lines.append(
f"[WARN] ignored 名级差异 {len(only_b_names)} 项全部由 "
f"missing_final 解释——cargo 口径采信"
)
else:
rc = max(rc, 1)
lines += report_diff("ignored 集合 (nextest vs cargo, multiset)", d2)
lines.append("[FAIL] ignored 交叉不一致且无法由 missing_final 解释")
total_p = n_meta["passed"] + sum(d_pass.values())
total_f = n_meta["failed"] + sum(d_fail.values())
total_i = sum(c_ign.values()) + sum(d_ign.values())
lines.append(
f"锚合并: {total_p} passed / {total_f} failed / {total_i} ignored "
f"(ignored=cargo 人类口径 {sum(c_ign.values())}+doc {sum(d_ign.values())})"
)
return rc, lines
def report_diff(label: str, d: dict, max_show: int = 20) -> list[str]:
lines = [f"[{label}] {'一致 ✓' if d['equal'] else '存在差异 ✗'}"]
for key in ("only_a", "only_b"):
items = list(d[key].items())[:max_show]
lines.append(f" {key} ({len(d[key])}): {[n for n, _ in items]}")
if len(d[key]) > max_show:
lines.append(f" ... 其余 {len(d[key]) - max_show} 项省略")
for n, c in list(d["count_mismatch"].items())[:max_show]:
lines.append(f" count_mismatch: {n} a={c['a']} b={c['b']}")
return lines
ENV_STRIP = ("XWCODE_ALLOW_DANGEROUS",)
def _run(cmd: list[str], env: dict) -> tuple[str, str, float]:
"""执行命令, 分离返回 (stdout, stderr, 墙钟)。
捕获卫生 (claude-test AC1 复算 03:21): json 流与人类输出禁同文件——
stdout(JSON) 单独落盘, stderr(人类 SKIP/进度行)另行记录, 合并落盘会
混入非 JSON 行且掩盖 ignored 事件缺失。
"""
t0 = time.monotonic()
r = subprocess.run(cmd, capture_output=True, text=True, env=env)
wall = time.monotonic() - t0
return r.stdout, r.stderr, wall
def compare_vs_cargo(save_dir: str | None = None) -> int:
"""三跑实测对账: nextest 全量 / cargo test --tests 全量 / cargo test --doc 全量。
cargo 侧 --no-fail-fast: 对账要求双侧全量跑完 (fail-fast 中止后续 binary
会使 cargo passed 集合残缺, 首跑实证: 1 红测中止 → 12 项假性缺失)。
save_dir 提供时落盘三份原始输出 (留痕可复算)。
"""
env = dict(sys_exec_env())
for k in ENV_STRIP:
env.pop(k, None)
env.setdefault("NEXTEST_EXPERIMENTAL_LIBTEST_JSON", "1")
print("== [1/3] cargo nextest run --workspace (libtest-json) ==", flush=True)
n_out, n_err, n_wall = _run(
["cargo", "nextest", "run", "--workspace", "--message-format", "libtest-json"], env
)
n_counter, n_meta = parse_nextest_json(n_out)
print("== [2/3] cargo test --workspace --tests --no-fail-fast ==", flush=True)
c_out, c_err, c_wall = _run(["cargo", "test", "--workspace", "--tests", "--no-fail-fast"], env)
c_pass, c_fail, c_ign = parse_cargo_test(c_out)
print("== [3/3] cargo test --workspace --doc ==", flush=True)
d_out, d_err, d_wall = _run(["cargo", "test", "--workspace", "--doc"], env)
d_pass, d_fail, d_ign = parse_cargo_doctest(d_out)
if save_dir:
import pathlib
p = pathlib.Path(save_dir)
p.mkdir(parents=True, exist_ok=True)
(p / "nextest.json").write_text(n_out)
(p / "nextest.human.log").write_text(n_err)
(p / "cargo_tests.txt").write_text(c_out)
(p / "cargo_doc.txt").write_text(d_out)
print(f"\n原始输出已落盘 {p}/ (复算: --from-files ...; json 与人类输出已分离)")
rc, report = reconcile(n_counter, n_meta, c_pass, c_ign, d_pass, d_fail, d_ign)
print()
for line in report:
print(line)
if n_meta["failed"] > 0:
failed_names = _nextest_failed_names(n_out)
if failed_names:
print(f" nextest 红测名单 ({len(failed_names)}): {failed_names}")
return rc
print(f"\nnextest 墙钟 {n_wall:.1f}s | cargo --tests 墙钟 {c_wall:.1f}s | "
f"cargo --doc 墙钟 {d_wall:.1f}s (含编译段)")
print(f"nextest 执行段(测试内)合计 {n_meta['exec_total']}s | "
f"top slow: {n_meta['top_slow'][:5]}")
print(f"\nnextest: passed={n_meta['passed']} failed={n_meta['failed']} "
f"ignored={n_meta['ignored']} | cargo --tests 参照: "
f"{sum(c_pass.values())}/{sum(c_fail.values())}/{sum(c_ign.values())}")
return rc
def sys_exec_env() -> dict:
return dict(__import__("os").environ)
def self_test() -> int:
"""构造样本流自测: 解析正确性 + 规范化 + multiset 对账 (ar 验收要求)。"""
checks: list[tuple[str, bool]] = []
nxt = "\n".join([
'{"type":"suite","event":"started","test_count":4}',
'{"type":"test","event":"started","name":"xw-tui::xw_tui$app::tests::a"}',
'{"type":"test","event":"ok","name":"xw-tui::xw_tui$app::tests::a","exec_time":0.01}',
'{"type":"test","event":"failed","name":"xw-tui::types_v0700$test_b"}',
'{"type":"test","event":"ignored","name":"xw-tui::xw_tui$app::tests::c"}',
'{"type":"test","event":"started","name":"xw-tui::xw_tui$app::tests::skipme"}',
'{"type":"test","event":"ok","name":"xw-tui::xw_tui$app::tests::skipme","exec_time":0.02}',
'non-json noise line',
])
c1, m1 = parse_nextest_json(nxt)
checks.append(("nextest: 规范化去 $ 前缀", c1 == {"app::tests::a": 1, "app::tests::skipme": 1}))
checks.append(("nextest: ok/failed/ignored 分列", m1["passed"] == 2 and m1["failed"] == 1 and m1["ignored"] == 1))
checks.append(("nextest: exec_total 与 top_slow", abs(m1["exec_total"] - 0.03) < 1e-9 and m1["top_slow"][0][1] == "app::tests::skipme"))
cg = "\n".join([
"test app::tests::a ... ok",
"test app::tests::a ... ok",
"test test_b ... FAILED",
"test app::tests::c ... ignored",
"test test_panic_case - should panic ... ok",
chr(27) + "]11;?" + chr(27) + chr(92) + "test app::tests::osc_glued ... ok",
"running 10 tests",
"test result: ok. 4 passed; 0 failed",
])
p2, f2, i2 = parse_cargo_test(cg)
checks.append(("cargo: multiset 计数(同名跨target=2)+should_panic 形态",
p2 == {"app::tests::a": 2, "test_panic_case": 1, "app::tests::osc_glued": 1}
and f2 == {"test_b": 1} and i2 == {"app::tests::c": 1}))
dc = "\n".join([
"test src/lib.rs - hello (line 5) ... ok",
"test src/tools.rs - example (line 12) ... ok",
"test app::tests::a ... ok",
"test src/lib.rs - bad (line 9) ... FAILED",
])
p3, f3, _ = parse_cargo_doctest(dc)
checks.append(("doctest: 仅匹配 doc 样式名", p3 == {"src/lib.rs - hello (line 5)": 1, "src/tools.rs - example (line 12)": 1} and f3 == {"src/lib.rs - bad (line 9)": 1}))
d_eq = diff_counters({"a": 2, "b": 1}, {"a": 2, "b": 1})
d_ne = diff_counters({"a": 2}, {"a": 1, "b": 3})
checks.append(("diff: 相等集合", d_eq["equal"]))
checks.append(("diff: 计数差+缺失+多余检出",
not d_ne["equal"] and d_ne["only_a"] == {} and d_ne["only_b"] == {"b": 3}
and d_ne["count_mismatch"] == {"a": {"a": 2, "b": 1}}))
merged = (m1["passed"] + sum(p3.values()), m1["failed"] + sum(f3.values()), m1["ignored"])
checks.append(("锚合并: nextest+doctest 计数", merged == (4, 2, 1)))
ok = True
for name, passed in checks:
print(f" {'PASS' if passed else 'FAIL'} {name}")
ok &= passed
print(f"self-test: {'ALL PASS' if ok else 'FAILED'} ({len(checks)} checks)")
return 0 if ok else 2
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--self-test", action="store_true", help="构造样本流自测")
ap.add_argument("--from-files", nargs=3, metavar=("NEXT_JSON", "CARGO_TXT", "DOC_TXT"),
help="从落盘输出解析并对账")
ap.add_argument("--compare-vs-cargo", action="store_true",
help="实跑三命令全量对账 (O-2 workspace 全量)")
ap.add_argument("--save-dir", metavar="DIR",
help="compare-vs-cargo 模式下落盘三份原始输出 (留痕可复算)")
args = ap.parse_args()
if args.self_test:
return self_test()
if args.from_files:
n_counter, n_meta = parse_nextest_json(open(args.from_files[0]).read())
c_pass, c_fail, c_ign = parse_cargo_test(open(args.from_files[1]).read())
d_pass, d_fail, d_ign = parse_cargo_doctest(open(args.from_files[2]).read())
rc, report = reconcile(n_counter, n_meta, c_pass, c_ign, d_pass, d_fail, d_ign)
print("\n".join(report))
return rc
if args.compare_vs_cargo:
return compare_vs_cargo(save_dir=args.save_dir)
ap.print_help()
return 0
if __name__ == "__main__":
sys.exit(main())