fix(tooling): 补全PR测试报告解析并修复并发测试

- 更新 gframework-pr-review 脚本以提取 CTRF 测试摘要和失败用例详情

- 修复 SettingsModelTests 在 NET9+ 下错误使用 Monitor 持锁的并发测试语义

- 同步 analyzer-warning-reduction 的 active todo 与 trace 真值
This commit is contained in:
gewuyou 2026-04-27 09:53:12 +08:00
parent 1f560635a8
commit 1c87272f6b
5 changed files with 263 additions and 70 deletions

View File

@ -62,6 +62,7 @@ The script should produce:
- Pre-merge failed checks, if present
- Latest MegaLinter status and any detailed issues posted by `github-actions[bot]`
- Test summary, including failed-test signals when present
- Detailed failed-test rows from GitHub Test Reporter / CTRF comments when the PR comment includes `Name` / `Failure Message` content
- CLI support for writing full JSON to a file and printing only narrowed text sections to stdout
- Parse warnings only when both the primary API source and the intended fallback signal are unavailable

View File

@ -257,6 +257,11 @@ def strip_markdown_links(text: str) -> str:
return re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text)
def strip_markdown_images(text: str) -> str:
"""Drop Markdown image syntax while keeping surrounding text readable."""
return re.sub(r"!\[[^\]]*\]\([^)]+\)", "", text)
def extract_section(text: str, start_marker: str, end_markers: list[str]) -> str | None:
"""Extract text between a start marker and the earliest matching end marker."""
start = text.find(start_marker)
@ -486,43 +491,190 @@ def parse_megalinter_comment(comment_body: str) -> dict[str, Any]:
return report
def clean_markdown_table_cell(text: str) -> str:
"""Normalize a Markdown table cell for structured parsing."""
cleaned = strip_markdown_images(strip_markdown_links(html.unescape(text)))
cleaned = cleaned.replace("\xa0", " ")
cleaned = cleaned.replace("**", "").replace("*", "").replace("`", "")
return collapse_whitespace(cleaned)
def parse_int_from_text(text: str) -> int | None:
"""Extract the first integer value from text."""
match = re.search(r"\d+", text)
return int(match.group(0)) if match else None
def parse_duration_from_text(text: str) -> str:
"""Extract a duration token from text when present."""
match = re.search(r"\d+(?:\.\d+)?(?:ms|s|m|h)", text)
if match is not None:
return match.group(0)
return collapse_whitespace(text)
def parse_markdown_table(table_text: str) -> tuple[list[str], list[list[str]]]:
"""Parse a Markdown table into header cells and row cells."""
lines = [line.strip() for line in table_text.splitlines() if line.strip().startswith("|")]
if len(lines) < 2:
return [], []
headers = [clean_markdown_table_cell(cell) for cell in lines[0].strip("|").split("|")]
rows: list[list[str]] = []
for line in lines[2:]:
cells = [clean_markdown_table_cell(cell) for cell in line.strip("|").split("|")]
if cells:
rows.append(cells)
return headers, rows
def extract_markdown_table_after_heading(block: str, heading: str) -> tuple[list[str], list[list[str]]]:
"""Extract the first Markdown table that appears after a heading."""
section = extract_section(block, heading, ["\n### ", "\n#### ", "\n<details>", "\n<table>", "\n<sub>"])
if section is None:
return [], []
table_match = re.search(r"(\|.*\|\n\|[-| :]+\|\n(?:\|.*\|\n?)*)", section, re.S)
if table_match is None:
return [], []
return parse_markdown_table(table_match.group(1))
def normalize_stat_header(header: str) -> str:
"""Normalize a human-readable stats header into a stable machine key."""
ascii_only = re.sub(r"[^A-Za-z]+", "", header).lower()
aliases = {
"tests": "tests",
"passed": "passed",
"failed": "failed",
"skipped": "skipped",
"pending": "pending",
"other": "other",
"flaky": "flaky",
"duration": "duration",
}
return aliases.get(ascii_only, ascii_only)
def parse_stats_table(headers: list[str], rows: list[list[str]]) -> dict[str, Any]:
"""Convert a parsed Markdown stats table into the report stats shape."""
if not headers or not rows:
return {}
first_row = rows[0]
stats: dict[str, Any] = {}
for header, value in zip(headers, first_row):
key = normalize_stat_header(header)
if not key:
continue
if key == "duration":
stats[key] = parse_duration_from_text(value)
continue
parsed_value = parse_int_from_text(value)
if parsed_value is not None:
stats[key] = parsed_value
return stats
def normalize_failure_message(text: str) -> str:
"""Normalize a failed-test message while preserving the meaningful lines."""
cleaned = html.unescape(text)
cleaned = re.sub(r"(?i)<br\s*/?>", "\n", cleaned)
cleaned = re.sub(r"</?(?:p|div|tbody|thead|tr|td|th|table)>", "\n", cleaned)
cleaned = re.sub(r"<[^>]+>", " ", cleaned)
lines = [collapse_whitespace(line) for line in cleaned.splitlines()]
meaningful_lines = [line for line in lines if line]
return "\n".join(meaningful_lines)
def parse_failed_test_summary_list(block: str) -> list[str]:
"""Parse the compact failed-tests summary list from CTRF details blocks."""
failed_tests_section = re.search(
r"<details><summary><strong>\s*Failed Tests.*?</summary>(?P<body>.*?)</details>",
block,
re.S,
)
if failed_tests_section is None:
return []
summary_body = strip_markdown_links(strip_markdown_images(html.unescape(failed_tests_section.group("body"))))
failed_tests: list[str] = []
for raw_line in summary_body.splitlines():
line = collapse_whitespace(raw_line)
if not line:
continue
if "arrow-right" in raw_line:
parts = [part.strip() for part in line.split("arrow-right") if part.strip()]
candidate = parts[-1] if parts else line
elif ">" in line:
candidate = line.split(">")[-1].strip()
else:
candidate = line
if candidate:
failed_tests.append(candidate)
return failed_tests
def parse_failed_test_details(block: str) -> list[dict[str, str]]:
"""Parse the detailed failed-test HTML table from GitHub Test Reporter comments."""
details: list[dict[str, str]] = []
table_section = re.search(
r"### ❌ \*\*Some tests failed!\*\*.*?<tbody>(?P<body>.*?)</tbody>",
block,
re.S,
)
if table_section is None:
return details
for name_cell, message_cell in re.findall(r"<tr>\s*<td>(.*?)</td>\s*<td>(.*?)</td>\s*</tr>", table_section.group("body"), re.S):
name = collapse_whitespace(strip_tags(html.unescape(name_cell))).lstrip("").strip()
failure_message = normalize_failure_message(message_cell)
if name:
details.append(
{
"name": name,
"failure_message": failure_message,
}
)
return details
def parse_test_report(block: str) -> dict[str, Any]:
"""Parse a CTRF or GitHub test-reporter comment block."""
report: dict[str, Any] = {
"raw": block.strip(),
"stats": {},
"failed_tests": [],
"failed_test_details": [],
"has_failed_tests": False,
}
summary_row_match = re.search(
r"\|\s*\*?\*?(\d+)\*?\*?\s*\|\s*\*?\*?(\d+)\*?\*?\s*\|\s*\*?\*?(\d+)\*?\*?\s*\|"
r"\s*\*?\*?(\d+)\*?\*?\s*\|\s*\*?\*?(\d+)\*?\*?\s*\|\s*\*?\*?(\d+)\*?\*?\s*\|\s*\*?\*?([^\|]+?)\*?\*?\s*\|",
block,
)
if summary_row_match is not None:
report["stats"] = {
"tests": int(summary_row_match.group(1)),
"passed": int(summary_row_match.group(2)),
"failed": int(summary_row_match.group(3)),
"skipped": int(summary_row_match.group(4)),
"other": int(summary_row_match.group(5)),
"flaky": int(summary_row_match.group(6)),
"duration": summary_row_match.group(7).strip(),
}
summary_headers, summary_rows = extract_markdown_table_after_heading(block, "### Summary")
report["stats"] = parse_stats_table(summary_headers, summary_rows)
failed_tests_section = extract_section(
block,
"### Failed Tests",
["### Slowest Tests", "### Insights", "<sub>", "[Github Test Reporter]"],
)
if failed_tests_section:
lines = [line.strip("- ").strip() for line in failed_tests_section.splitlines()[1:] if line.strip()]
report["failed_tests"] = lines
report["has_failed_tests"] = True
elif "No failed tests in this run." in block or "All tests passed!" in block:
report["failed_tests"] = []
report["has_failed_tests"] = False
if not report["stats"]:
build_headers, build_rows = extract_markdown_table_after_heading(block, "### build-and-test:")
report["stats"] = parse_stats_table(build_headers, build_rows)
failed_test_details = parse_failed_test_details(block)
failed_test_names = parse_failed_test_summary_list(block)
if not failed_test_names and failed_test_details:
failed_test_names = [detail["name"] for detail in failed_test_details]
report["failed_tests"] = failed_test_names
report["failed_test_details"] = failed_test_details
failed_count = int(report["stats"].get("failed", 0) or 0)
report["has_failed_tests"] = bool(failed_test_names or failed_test_details or failed_count > 0)
return report
@ -1103,8 +1255,17 @@ def format_text(
lines.append(f"- Report {index}: no structured test stats parsed")
if report["has_failed_tests"]:
for failed_test in report["failed_tests"]:
lines.append(f" Failed test: {truncate_text(failed_test, max_description_length)}")
failed_test_details = report.get("failed_test_details", [])
if failed_test_details:
for failed_test_detail in failed_test_details:
lines.append(f" Failed test: {truncate_text(failed_test_detail['name'], max_description_length)}")
lines.append(
" Failure: "
f"{truncate_text(failed_test_detail['failure_message'].replace(chr(10), ' | '), max_description_length)}"
)
else:
for failed_test in report["failed_tests"]:
lines.append(f" Failed test: {truncate_text(failed_test, max_description_length)}")
else:
lines.append(" Failed tests: none reported")

View File

@ -137,12 +137,10 @@ public sealed class SettingsModelTests
var migrationMapLock = lockField!.GetValue(model);
Assert.That(migrationMapLock, Is.Not.Null);
Task initializeTask;
Task registerTask;
lock (migrationMapLock!)
var tasks = WithSynchronizationLockHeld(migrationMapLock!, () =>
{
initializeTask = Task.Run(() => model.InitializeAsync());
registerTask = Task.Run(() => model.RegisterMigration(new TestLatestSettingsMigrationV2ToV3()));
var initializeTask = Task.Run(() => model.InitializeAsync());
var registerTask = Task.Run(() => model.RegisterMigration(new TestLatestSettingsMigrationV2ToV3()));
Thread.Sleep(50);
@ -151,7 +149,11 @@ public sealed class SettingsModelTests
Assert.That(initializeTask.IsCompleted, Is.False);
Assert.That(registerTask.IsCompleted, Is.False);
});
}
return (initializeTask, registerTask);
});
var (initializeTask, registerTask) = tasks;
await Task.WhenAll(initializeTask, registerTask);
@ -171,6 +173,35 @@ public sealed class SettingsModelTests
});
}
/// <summary>
/// 以与被测代码相同的同步原语持有反射获取到的锁对象,避免在 .NET 9+ 上把 <see cref="System.Threading.Lock" />
/// 退化成 <see cref="Monitor" /> 语义,导致并发测试误判。
/// </summary>
/// <param name="syncRoot">通过反射读取到的私有锁字段。</param>
/// <typeparam name="TResult">持锁代码返回的结果类型。</typeparam>
/// <param name="action">持锁期间执行的断言与并发调度逻辑。</param>
/// <returns>持锁代码的返回值。</returns>
private static TResult WithSynchronizationLockHeld<TResult>(object syncRoot, Func<TResult> action)
{
ArgumentNullException.ThrowIfNull(syncRoot);
ArgumentNullException.ThrowIfNull(action);
#if NET9_0_OR_GREATER
if (syncRoot is System.Threading.Lock typedLock)
{
using (typedLock.EnterScope())
{
return action();
}
}
#endif
lock (syncRoot)
{
return action();
}
}
private sealed class TestSettingsData : ISettingsData
{
public string Value { get; set; } = "default";

View File

@ -6,12 +6,12 @@
## 当前恢复点
- 恢复点编号:`ANALYZER-WARNING-REDUCTION-RP-079`
- 当前阶段:`Phase 79`
- 恢复点编号:`ANALYZER-WARNING-REDUCTION-RP-080`
- 当前阶段:`Phase 80`
- 当前焦点:
- `2026-04-27`按 PR `#295` 的 latest-head review 收口本地仍成立的问题:`AsyncExtensionsTests``ArgumentException.ParamName` 契约与 active `ai-plan` 文档过长问题
- 当前轮次已重新确认 `origin/main` 基线与 `HEAD` 同为 `617e0bf`;当前已提交 `HEAD` 的 stop metric 仍为 `30 / 50` files、`642` changed lines本轮 PR review 同步尚未提交入该指标
- 当前剩余 warning 主要集中在 `YamlConfigSchemaValidator*``YamlConfigLoader.cs` 与大批量 `MA0048` 文件名拆分;这些 slice 仍高于本轮 PR review 收口的低风险边界
- `2026-04-27`补齐 `$gframework-pr-review` 对 GitHub Test Reporter / CTRF 测试摘要与失败用例详情的提取,当前文本输出可以直接显示失败测试名和 failure message 摘要
- `SettingsModelTests.RegisterMigration_During_Cache_Rebuild_Should_Not_Leave_Stale_Type_Cache` 已按 `System.Threading.Lock` 的真实语义修正测试实现,并完成针对性 Release 测试验证
- 当前剩余 warning 热点仍集中在 `YamlConfigSchemaValidator*``YamlConfigLoader.cs` 与大批量 `MA0048` 文件名拆分;这些 slice 仍高于本轮 PR review / test follow-up 的低风险边界
## 当前活跃事实
@ -19,12 +19,10 @@
- 当前 PR review 真值:
- `python3 .agents/skills/gframework-pr-review/scripts/fetch_current_pr_review.py --json-output <current-pr-review-json>`
- 最新结果:成功;当前分支对应 PR 为 `#295`
- latest-head review 在本轮修复前共有 `2` 条 open thread分别指向 `GFramework.Core.Tests/Extensions/AsyncExtensionsTests.cs` 与 active `trace`
- 本轮已在本地收口上述两类问题,待提交后可重新执行 `$gframework-pr-review` 确认线程自动收口
- 提权后的直接验证当前确认为:
- `dotnet build GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release`
- 最新结果:成功;`126 Warning(s)``0 Error(s)`
- 当前仍可见的既有 warning ID`CS8766``CS8625``CS8602``CS8618``MA0048``MA0002``MA0008`
- 当前测试报告输出已能显示 `Summary` 统计、失败测试名称,以及 `Name / Failure Message` 表格中的关键信息
- 当前直接验证结果:
- `dotnet test GFramework.Game.Tests/GFramework.Game.Tests.csproj -c Release --filter "FullyQualifiedName~RegisterMigration_During_Cache_Rebuild_Should_Not_Leave_Stale_Type_Cache"`
- 最新结果:成功;`Failed: 0, Passed: 1, Skipped: 0, Total: 1`
- 当前分支 stop-condition 指标:
- `git diff --name-only refs/remotes/origin/main...HEAD | wc -l`
- 最新结果:`30`
@ -33,6 +31,7 @@
- 当前批次摘要:
- 三轮低风险 warning 清理已在此前验证中将仓库根 warning 从 `639` 降到 `397`
- 当前批次的已完成 slice 明细已迁移到归档active todo 仅保留恢复真值
- 本轮新增内容为 PR review 工具链补强与单个失败测试修正,不扩展 warning reduction 的热点清理边界
- 当前建议保留到下一波次的候选:
- `GFramework.Game/Config/YamlConfigLoader.cs``MA0158`(单点可修,但文件本身同时承载其他高耦合 warning
- 测试项目中的 `MA0048` 文件名拆分波次(会显著增加 changed-file 数)
@ -43,8 +42,8 @@
- 缓解措施:本轮先避开该热点,只清理低风险且 ownership 清晰的文件集合。
- `MA0158` 迁移涉及 `net8.0` / `net9.0` / `net10.0` 多目标兼容。
- 缓解措施:复用 `StoreSelection.cs` 已存在的 `#if NET9_0_OR_GREATER` 专用锁模式,不在 `net8.0` 引入不兼容 API。
- 当前 PR open thread 的自动收口仍依赖新提交进入远端 PR head
- 缓解措施:本轮提交后重新执行 `$gframework-pr-review`仅以最新 head review 为准
- 当前 PR open thread 与 CI 失败信号仍依赖新提交进入远端 PR head 才能复核
- 缓解措施:本轮提交后重新执行 `$gframework-pr-review`同时确认 review thread 与 failed test signal 是否一起收口
## 活跃文档
@ -69,6 +68,6 @@
## 下一步建议
1. 提交本轮 PR review 修复与 `ai-plan` 精简同步,再重跑 `$gframework-pr-review` 验证 PR `#295` 的 open thread 是否随新 head 收口
2. 若后续继续推进 warning reduction建议另开下一波次优先明确是否接受 `YamlConfigLoader.cs` 热点触碰,或是否要专门做测试项目 `MA0048` 拆分波次
3. 默认在当前恢复点停下因为继续推进已不再符合本轮“PR review 收口 + 少文件同步”的边界
1. 提交本轮 `$gframework-pr-review` 解析增强、`SettingsModelTests` 修复与 `ai-plan` 同步
2. 推送后重新执行 `$gframework-pr-review`,确认 PR `#295` 的 failed test detail 与 open thread 是否已更新为新 head 真值
3. 若后续继续推进 warning reduction建议另开下一波次处理 `YamlConfigLoader.cs` 热点或测试项目 `MA0048` 拆分波次

View File

@ -1,37 +1,38 @@
# Analyzer Warning Reduction 追踪
## 2026-04-27 — RP-079
## 2026-04-27 — RP-080
### 阶段:按 PR `#295` latest-head review 收口当前仍成立的 open thread
### 阶段:补强 `$gframework-pr-review` 的测试报告提取并修复 `SettingsModelTests` 失败用例
- 触发背景:
- 重新执行 `$gframework-pr-review` 后,确认当前分支对应的最新公开 PR 为 `#295`,而不是旧 trace 中记录的 `#291`
- latest-head review 仍有 `2` 条 open thread分别指向 `GFramework.Core.Tests/Extensions/AsyncExtensionsTests.cs` 的参数名契约问题,以及 active trace 过长问题
- 用户补充了 GitHub Test Reporter / CTRF 的完整 PR 评论,指出现有 `$gframework-pr-review` 输出虽然能显示 `failed=1`,但没有抓到失败测试名称与详细报错
- PR 评论中的真实失败用例为 `RegisterMigration_During_Cache_Rebuild_Should_Not_Leave_Stale_Type_Cache`,报错集中在测试通过反射持锁后两个任务提前完成
- 主线程实施:
- 修正 `ThrowShouldNotRetry``ArgumentException``ParamName` 传递逻辑,并补充断言锁定 `nameof(taskFactory)` 契约
- 将 active trace 精简为单一恢复入口,并把 `RP073``RP078` 的详细过程迁入归档
- 同步压缩 active todo只保留当前恢复点真值与归档指针
- 扩展 `.agents/skills/gframework-pr-review/scripts/fetch_current_pr_review.py``parse_test_report`,增加 CTRF `Summary` 统计、紧凑 failed-tests 列表、以及 `Name` / `Failure Message` HTML 表格的提取
- 更新 `gframework-pr-review` skill 文档的输出预期,明确脚本现在应提取 GitHub Test Reporter / CTRF 的失败详情
- 修正 `GFramework.Game.Tests/Setting/SettingsModelTests.cs`,让反射取得的 `_migrationMapLock``NET9_0_OR_GREATER` 下通过 `System.Threading.Lock.EnterScope()` 持锁,而不是退化成 `Monitor` 语义
- 验证里程碑:
- `python3 .agents/skills/gframework-pr-review/scripts/fetch_current_pr_review.py --json-output <current-pr-review-json>`
- 结果:成功;确认 PR `#295` latest-head review 在本轮修复前共有 `2` 条 open thread
- `dotnet build GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release`
- 结果:成功;`126 Warning(s)``0 Error(s)`
- 当前可见的既有 warning ID`CS8766``CS8625``CS8602``CS8618``MA0048``MA0002``MA0008`
- `python3 -c "... parse_test_report(...)"`(基于 `/tmp/current-pr-review.json` 的现有原始评论)
- 结果:成功;已能解析 `tests=2156``passed=2155``failed=1``duration=35.3s`,并抓到 `RegisterMigration_During_Cache_Rebuild_Should_Not_Leave_Stale_Type_Cache` 的 failure message
- `python3 .agents/skills/gframework-pr-review/scripts/fetch_current_pr_review.py --section tests --json-output /tmp/current-pr-review-v2.json`
- 结果:成功;文本输出已直接显示失败测试名与报错摘要
- `dotnet test GFramework.Game.Tests/GFramework.Game.Tests.csproj -c Release --filter "FullyQualifiedName~RegisterMigration_During_Cache_Rebuild_Should_Not_Leave_Stale_Type_Cache"`
- 结果:成功;`Failed: 0, Passed: 1, Skipped: 0, Total: 1`
- 当前结论:
- 当前仍成立的 latest-head review 问题已经在本地收口,下一步应以新提交刷新 PR head 再复查
- active `ai-plan` 入口现已回到“当前真值 + 归档指针”的体量,符合仓库对恢复文档的要求
- `$gframework-pr-review` 现在已经能把 PR 测试评论里的关键失败用例信息直接提出来,不再只停留在 `failed=1`
- `SettingsModelTests` 当前失败是测试锁语义与生产代码不一致导致的误判,已在本地复现并修正通过
## 活跃风险
- PR review thread 是否自动关闭仍取决于新提交是否进入远端 PR head
- 缓解措施:提交后重新执行 `$gframework-pr-review`,只以新的 latest-head 结果为准。
- PR 上的 latest-head review thread 与测试报告仍需要等新提交进入远端后再复核
- 缓解措施:提交并推送后重新执行 `$gframework-pr-review`,只以新的 latest-head 和 test report 为准。
- `YamlConfigSchemaValidator*``YamlConfigLoader.cs``MA0048` 拆分仍是下一波次的高耦合候选。
- 缓解措施:保持本轮边界只处理 PR review 收口,不顺手扩展 warning reduction 范围。
- 缓解措施:保持本轮边界只处理 PR review 工具链与失败测试,不顺手扩展 warning reduction 范围。
## 下一步
1. 完成本轮验证并提交。
2. 重新执行 `$gframework-pr-review`,确认 PR `#295` latest-head 是否还有 unresolved thread
1. 完成本轮提交。
2. 推送后重新执行 `$gframework-pr-review`,确认 PR `#295` 的 failed test detail 与 unresolved thread 是否已刷新
## 历史归档指针