Quellcode durchsuchen

fix: harden author reporting release checks

lingfengQAQ vor 2 Wochen
Ursprung
Commit
e592509b8d

+ 39 - 6
webnovel-writer/scripts/data_modules/run_ledger.py

@@ -5,18 +5,29 @@ from __future__ import annotations
 import argparse
 import hashlib
 import json
+import sys
 from datetime import datetime, timezone
 from pathlib import Path
 from typing import Any
 
+if __package__ in {None, ""}:  # pragma: no cover - direct script entry
+    scripts_dir = Path(__file__).resolve().parents[1]
+    if str(scripts_dir) not in sys.path:
+        sys.path.insert(0, str(scripts_dir))
+
 try:
     from chapter_paths import find_chapter_file
 except ImportError:  # pragma: no cover
     from scripts.chapter_paths import find_chapter_file
 
-from .artifact_validator import OK_PROJECTION_STATUSES, REQUIRED_PROJECTION_WRITERS
-from .project_phase import COMMIT_ARTIFACT_FILES, contract_files_for_chapter
-from .projection_log import latest_projection_run, projection_status_from_run
+if __package__ in {None, ""}:  # pragma: no cover - direct script entry
+    from data_modules.artifact_validator import OK_PROJECTION_STATUSES, REQUIRED_PROJECTION_WRITERS
+    from data_modules.project_phase import COMMIT_ARTIFACT_FILES, contract_files_for_chapter
+    from data_modules.projection_log import latest_projection_run, projection_status_from_run
+else:
+    from .artifact_validator import OK_PROJECTION_STATUSES, REQUIRED_PROJECTION_WRITERS
+    from .project_phase import COMMIT_ARTIFACT_FILES, contract_files_for_chapter
+    from .projection_log import latest_projection_run, projection_status_from_run
 
 
 SCHEMA_VERSION = "webnovel-run-ledger/v1"
@@ -216,8 +227,8 @@ def build_write_resume_plan(
     review_entry = _step_completed(run, "review")
     data_entry = _step_completed(run, "data")
     commit_status = _commit_status(root, chapter)
-    commit_done = commit_status in {"accepted", "rejected"}
     accepted_done = commit_status == "accepted"
+    rejected_done = commit_status == "rejected"
 
     steps: list[dict[str, str]] = []
     confirmations: list[dict[str, str]] = []
@@ -255,10 +266,32 @@ def build_write_resume_plan(
                 "message": "本章已 accepted;重跑前需要确认是重写正文,还是只查看状态/补跑后续步骤。",
             }
         )
-    steps.append({"step": "commit", "action": "skip" if commit_done else "run", "reason": f"commit status={commit_status}" if commit_done else "尚未生成 commit"})
+    if rejected_done:
+        confirmations.append(
+            {
+                "code": "chapter_commit_rejected",
+                "message": "本章事实提交未通过,需要先处理审查/大纲/消歧阻断项,再重新提交。",
+            }
+        )
+    commit_reason = (
+        f"commit status={commit_status}"
+        if accepted_done
+        else "commit rejected,需要修复后重新提交"
+        if rejected_done
+        else "尚未生成 commit"
+    )
+    steps.append({"step": "commit", "action": "skip" if accepted_done else "run", "reason": commit_reason})
 
     projection_done = bool(commit_status == "accepted" and _projection_done(root, chapter))
-    steps.append({"step": "projection", "action": "skip" if projection_done else "retry", "reason": "资料更新已完成" if projection_done else "需要补跑资料更新"})
+    projection_action = "skip" if projection_done else ("retry" if accepted_done else "run")
+    projection_reason = (
+        "资料更新已完成"
+        if projection_done
+        else "commit accepted 后再更新资料"
+        if not accepted_done
+        else "需要补跑资料更新"
+    )
+    steps.append({"step": "projection", "action": projection_action, "reason": projection_reason})
 
     backup_done = _backup_exists(root, chapter)
     backup_action = "skip" if backup_done else ("retry" if commit_status == "accepted" else "run")

+ 50 - 0
webnovel-writer/scripts/data_modules/tests/test_run_ledger.py

@@ -119,3 +119,53 @@ def test_write_resume_retries_backup_after_commit_done(tmp_path: Path) -> None:
     assert actions["backup"] == "retry"
     assert plan["resume_from"] == "backup"
     assert any(item["code"] == "chapter_already_accepted" for item in plan["needs_user_confirmation"])
+
+
+def test_write_resume_reruns_commit_after_rejected_commit(tmp_path: Path) -> None:
+    _make_project(tmp_path)
+    chapter_file = tmp_path / "正文" / "第0001章.md"
+    chapter_file.write_text("正文\n", encoding="utf-8")
+    review_path = tmp_path / ".webnovel" / "tmp" / "review_results.json"
+    _write_json(review_path, {"blocking_count": 1})
+    fulfillment_path = tmp_path / ".webnovel" / "tmp" / "fulfillment_result.json"
+    disambiguation_path = tmp_path / ".webnovel" / "tmp" / "disambiguation_result.json"
+    extraction_path = tmp_path / ".webnovel" / "tmp" / "extraction_result.json"
+    _write_json(fulfillment_path, {"planned_nodes": [], "covered_nodes": [], "missed_nodes": [], "extra_nodes": []})
+    _write_json(disambiguation_path, {"pending": []})
+    _write_json(extraction_path, {"accepted_events": [], "state_deltas": [], "entity_deltas": []})
+    record_write_step(
+        tmp_path,
+        chapter=1,
+        step="draft",
+        status="completed",
+        outputs={"chapter_file": chapter_file},
+    )
+    record_write_step(
+        tmp_path,
+        chapter=1,
+        step="review",
+        status="completed",
+        inputs={"chapter_file": chapter_file},
+        outputs={"review_result": review_path},
+    )
+    record_write_step(
+        tmp_path,
+        chapter=1,
+        step="data",
+        status="completed",
+        inputs={"chapter_file": chapter_file},
+        outputs={
+            "fulfillment_result": fulfillment_path,
+            "disambiguation_result": disambiguation_path,
+            "extraction_result": extraction_path,
+        },
+    )
+    _write_json(tmp_path / ".story-system" / "commits" / "chapter_001.commit.json", _commit_payload("rejected"))
+
+    plan = build_write_resume_plan(tmp_path, chapter=1)
+
+    actions = {item["step"]: item["action"] for item in plan["steps"]}
+    assert actions["commit"] == "run"
+    assert actions["projection"] == "run"
+    assert plan["resume_from"] == "commit"
+    assert any(item["code"] == "chapter_commit_rejected" for item in plan["needs_user_confirmation"])

+ 17 - 0
webnovel-writer/scripts/data_modules/tests/test_user_report.py

@@ -153,6 +153,23 @@ def test_render_write_report_success(tmp_path: Path) -> None:
     assert "三、下一步建议" in text
 
 
+def test_render_write_report_uses_commit_snapshots_when_tmp_artifacts_are_cleaned(tmp_path: Path) -> None:
+    _write_success_case(tmp_path, chapter=1)
+    for path in (tmp_path / ".webnovel" / "tmp").glob("*_result.json"):
+        path.unlink()
+
+    report = build_user_report(tmp_path, stage="write", chapter=1)
+
+    assert report["overall_status"] == "completed"
+    assert not report["issues"]["must_handle"]
+    artifact_files = [
+        item for item in report["files"]
+        if item["label"] in {"review_result", "fulfillment_result", "disambiguation_result", "extraction_result"}
+    ]
+    assert artifact_files
+    assert all(item["path"].endswith("chapter_001.commit.json") for item in artifact_files)
+
+
 def test_render_write_report_commit_rejected(tmp_path: Path) -> None:
     _write_success_case(tmp_path, chapter=1)
     payload = _commit_payload(chapter=1, status="rejected")

+ 126 - 29
webnovel-writer/scripts/data_modules/user_report.py

@@ -4,38 +4,72 @@ from __future__ import annotations
 
 import argparse
 import json
+import sys
 from pathlib import Path
 from typing import Any
 
+if __package__ in {None, ""}:  # pragma: no cover - direct script entry
+    scripts_dir = Path(__file__).resolve().parents[1]
+    if str(scripts_dir) not in sys.path:
+        sys.path.insert(0, str(scripts_dir))
+
 try:
     from chapter_paths import find_chapter_file
 except ImportError:  # pragma: no cover
     from scripts.chapter_paths import find_chapter_file
 
-from .artifact_validator import (
-    OK_PROJECTION_STATUSES,
-    REQUIRED_PROJECTION_WRITERS,
-    validate_disambiguation_result,
-    validate_extraction_result,
-    validate_fulfillment_result,
-    validate_review_result,
-)
-from .error_catalog import AuthorError, classify_issue
-from .project_phase import (
-    COMMIT_ARTIFACT_FILES,
-    INIT_REQUIRED_DIRS,
-    INIT_REQUIRED_FILES,
-    contract_files_for_chapter,
-)
-from .project_status import build_project_status
-from .projection_log import (
-    latest_projection_run,
-    projection_run_failed,
-    projection_run_pending,
-    projection_status_from_run,
-    read_projection_runs,
-)
-from .review_author_view import build_review_author_view
+if __package__ in {None, ""}:  # pragma: no cover - direct script entry
+    from data_modules.artifact_validator import (
+        OK_PROJECTION_STATUSES,
+        REQUIRED_PROJECTION_WRITERS,
+        validate_artifact_payload,
+        validate_disambiguation_result,
+        validate_extraction_result,
+        validate_fulfillment_result,
+        validate_review_result,
+    )
+    from data_modules.error_catalog import AuthorError, classify_issue
+    from data_modules.project_phase import (
+        COMMIT_ARTIFACT_FILES,
+        INIT_REQUIRED_DIRS,
+        INIT_REQUIRED_FILES,
+        contract_files_for_chapter,
+    )
+    from data_modules.project_status import build_project_status
+    from data_modules.projection_log import (
+        latest_projection_run,
+        projection_run_failed,
+        projection_run_pending,
+        projection_status_from_run,
+        read_projection_runs,
+    )
+    from data_modules.review_author_view import build_review_author_view
+else:
+    from .artifact_validator import (
+        OK_PROJECTION_STATUSES,
+        REQUIRED_PROJECTION_WRITERS,
+        validate_artifact_payload,
+        validate_disambiguation_result,
+        validate_extraction_result,
+        validate_fulfillment_result,
+        validate_review_result,
+    )
+    from .error_catalog import AuthorError, classify_issue
+    from .project_phase import (
+        COMMIT_ARTIFACT_FILES,
+        INIT_REQUIRED_DIRS,
+        INIT_REQUIRED_FILES,
+        contract_files_for_chapter,
+    )
+    from .project_status import build_project_status
+    from .projection_log import (
+        latest_projection_run,
+        projection_run_failed,
+        projection_run_pending,
+        projection_status_from_run,
+        read_projection_runs,
+    )
+    from .review_author_view import build_review_author_view
 
 
 SCHEMA_VERSION = "webnovel-user-report/v1"
@@ -219,8 +253,59 @@ def _validate_artifact_for_report(
     }
     validator = validators[artifact]
     result = validator(path)
+    return _add_artifact_validation_result(
+        report,
+        artifact=artifact,
+        result=result,
+        path=path,
+        ok_note="已生成",
+        error_note="缺失或格式不完整",
+    )
+
+
+def _validate_commit_artifact_for_report(
+    report: dict[str, Any],
+    artifact: str,
+    commit_payload: dict[str, Any],
+    commit_path: Path,
+) -> dict[str, Any]:
+    if artifact not in commit_payload:
+        result = {
+            "artifact": artifact,
+            "ok": False,
+            "errors": [
+                {
+                    "type": "missing_artifact",
+                    "message": f"chapter commit missing {artifact}",
+                    "impact": "commit 文件缺少提交 artifact 快照。",
+                    "repair": "重新执行 chapter-commit 生成完整 commit。",
+                }
+            ],
+            "payload": None,
+        }
+    else:
+        result = validate_artifact_payload(artifact, commit_payload.get(artifact), path=str(commit_path))
+    return _add_artifact_validation_result(
+        report,
+        artifact=artifact,
+        result=result,
+        path=commit_path,
+        ok_note="已存入本章事实提交",
+        error_note="commit 内 artifact 格式不完整",
+    )
+
+
+def _add_artifact_validation_result(
+    report: dict[str, Any],
+    *,
+    artifact: str,
+    result: dict[str, Any],
+    path: Path,
+    ok_note: str,
+    error_note: str,
+) -> dict[str, Any]:
     file_status = "completed" if result.get("ok") else "failed"
-    note = "已生成" if result.get("ok") else "缺失或格式不完整"
+    note = ok_note if result.get("ok") else error_note
     _add_file(report, label=artifact, path=_rel(Path(report["project_root"]), path), status=file_status, note=note)
     for item in result.get("errors") or []:
         issue = {
@@ -400,6 +485,8 @@ def _append_project_status_next_action(report: dict[str, Any], project_root: Pat
 def build_write_report(project_root: Path, *, chapter: int, volume: int | None = None) -> dict[str, Any]:
     report = _new_report(project_root, stage="write", chapter=chapter, volume=volume)
     core_files = 0
+    commit_path = _commit_path(project_root, chapter)
+    commit_payload, commit_error = _read_json(commit_path)
 
     chapter_file = find_chapter_file(project_root, chapter)
     if chapter_file:
@@ -426,8 +513,20 @@ def build_write_report(project_root: Path, *, chapter: int, volume: int | None =
         _add_file(report, label="审查报告", path="审查报告", status="missing", note="未找到审查报告文件")
 
     artifact_results: dict[str, dict[str, Any]] = {}
-    for artifact, path in _artifact_paths(project_root).items():
-        artifact_results[artifact] = _validate_artifact_for_report(report, artifact, path)
+    commit_has_artifact_snapshots = not commit_error and any(
+        artifact in commit_payload for artifact in _artifact_paths(project_root)
+    )
+    if commit_has_artifact_snapshots:
+        for artifact in _artifact_paths(project_root):
+            artifact_results[artifact] = _validate_commit_artifact_for_report(
+                report,
+                artifact,
+                commit_payload,
+                commit_path,
+            )
+    else:
+        for artifact, path in _artifact_paths(project_root).items():
+            artifact_results[artifact] = _validate_artifact_for_report(report, artifact, path)
 
     review_payload = artifact_results.get("review_result", {}).get("payload") or {}
     if isinstance(review_payload, dict) and review_payload.get("review_skipped"):
@@ -444,8 +543,6 @@ def build_write_report(project_root: Path, *, chapter: int, volume: int | None =
             path=COMMIT_ARTIFACT_FILES[0],
         )
 
-    commit_path = _commit_path(project_root, chapter)
-    commit_payload, commit_error = _read_json(commit_path)
     if commit_error:
         _add_file(report, label="本章事实提交", path=_rel(project_root, commit_path), status="missing", note="未生成 commit")
         _add_classified_issue(

+ 27 - 0
webnovel-writer/scripts/tests/test_validate_release_notes.py

@@ -96,3 +96,30 @@ def test_validate_release_notes_requires_previous_tag_in_release_note(tmp_path):
 
     assert report["ok"] is False
     assert any(item["code"] == "release_note.range" for item in report["issues"])
+
+
+def test_validate_release_notes_requires_previous_tag_in_current_changelog_section(tmp_path):
+    _write_release_files(tmp_path)
+    changelog = tmp_path / "CHANGELOG.md"
+    changelog.write_text(
+        """# 更新日志
+
+## v1.2.3 - 写章结果更清楚
+
+发版范围:上个版本到本版本。
+
+### 给作者看的变化
+
+- 作者写章反馈更清楚。
+
+## v1.2.2 - 旧版本
+
+发版范围:`v1.2.1..v1.2.2`。
+""",
+        encoding="utf-8",
+    )
+
+    report = validate_release_notes(tmp_path, version="1.2.3", previous_tag="v1.2.2")
+
+    assert report["ok"] is False
+    assert any(item["code"] == "changelog.range" for item in report["issues"])

+ 13 - 2
webnovel-writer/scripts/validate_release_notes.py

@@ -77,6 +77,16 @@ def _infer_previous_tag(root: Path, version: str) -> str:
     return sorted(candidates)[-1][1]
 
 
+def _changelog_section(text: str, version: str) -> str:
+    heading_re = re.compile(rf"^##\s+v{re.escape(version)}(?:\s|$)", re.MULTILINE)
+    match = heading_re.search(text)
+    if not match:
+        return ""
+    next_match = re.search(r"^##\s+", text[match.end():], re.MULTILINE)
+    end = match.end() + next_match.start() if next_match else len(text)
+    return text[match.start():end]
+
+
 def validate_release_notes(
     root: str | Path | None = None,
     *,
@@ -154,7 +164,8 @@ def validate_release_notes(
             )
         )
     else:
-        if f"## v{target_version}" not in changelog_text:
+        current_changelog_section = _changelog_section(changelog_text, target_version)
+        if not current_changelog_section:
             issues.append(
                 _issue(
                     "changelog.version",
@@ -163,7 +174,7 @@ def validate_release_notes(
                     repair="在 CHANGELOG.md 中新增当前版本小节。",
                 )
             )
-        if previous and previous not in changelog_text:
+        if previous and current_changelog_section and previous not in current_changelog_section:
             issues.append(
                 _issue(
                     "changelog.range",