Files
ljwx-deploy/scripts/knowledge/review_documents.py
T

236 lines
7.3 KiB
Python

#!/usr/bin/env python3
"""Review markdown documents before they enter the knowledge export workflow."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from .common import (
JSONMapping,
KnowledgeContractError,
control_files_changed,
load_review_entries,
load_source_rules,
match_globs,
parse_diff_entries,
require_non_empty_str,
resolve_document_review,
write_json,
)
def parse_args() -> argparse.Namespace:
"""Parse CLI arguments."""
parser = argparse.ArgumentParser(description="Review knowledge markdown sources")
parser.add_argument(
"--repo-root", type=Path, required=True, help="Business repo root"
)
parser.add_argument(
"--deploy-root",
type=Path,
required=True,
help="ljwx-deploy root used for contracts",
)
parser.add_argument(
"--config",
type=Path,
default=Path("knowledge.sources.yaml"),
help="Path to knowledge.sources.yaml relative to repo root",
)
parser.add_argument(
"--review-ledger",
type=Path,
default=Path("knowledge.review.yaml"),
help="Path to knowledge.review.yaml relative to repo root",
)
parser.add_argument(
"--changed-files",
type=Path,
help="Path to git diff --name-status output. If omitted, review all matches.",
)
parser.add_argument(
"--summary-json",
type=Path,
required=True,
help="Where to write review summary JSON",
)
parser.add_argument(
"--report-markdown",
type=Path,
required=True,
help="Where to write a markdown review report",
)
return parser.parse_args()
def resolve_rule_for_path(path: str, rules: list[object]) -> object | None:
"""Find the single matching rule for a source path."""
matched: list[object] = []
for rule in rules:
if not hasattr(rule, "include") or not hasattr(rule, "exclude"):
continue
include = getattr(rule, "include")
exclude = getattr(rule, "exclude")
if not isinstance(include, tuple) or not isinstance(exclude, tuple):
continue
if match_globs(path, include, exclude):
matched.append(rule)
if len(matched) > 1:
raise KnowledgeContractError(f"路径命中了多个 source rule: {path}")
return matched[0] if matched else None
def format_issue_line(path: str, source_id: str, issues: tuple[str, ...]) -> str:
"""Render one markdown bullet for a document issue list."""
issue_text = "; ".join(issues)
return f"- `{path}` (`{source_id}`): {issue_text}"
def build_report(
*,
reviewed_documents: int,
blocking_lines: list[str],
warning_lines: list[str],
passed_lines: list[str],
) -> str:
"""Build a markdown report suitable for GitHub step summaries."""
lines = [
"## Knowledge Review",
f"- Reviewed documents: {reviewed_documents}",
f"- Blocking documents: {len(blocking_lines)}",
f"- Warning documents: {len(warning_lines)}",
]
if blocking_lines:
lines.append("")
lines.append("### Blocking")
lines.extend(blocking_lines)
if warning_lines:
lines.append("")
lines.append("### Warnings")
lines.extend(warning_lines)
if passed_lines:
lines.append("")
lines.append("### Passed")
lines.extend(passed_lines)
if reviewed_documents == 0:
lines.append("")
lines.append("- No matched markdown documents for this change set.")
return "\n".join(lines) + "\n"
def main() -> int:
"""CLI entry point."""
args = parse_args()
repo_root = args.repo_root.resolve()
deploy_root = args.deploy_root.resolve()
config_path = (repo_root / args.config).resolve()
review_ledger_path = (repo_root / args.review_ledger).resolve()
summary_path = args.summary_json.resolve()
report_path = args.report_markdown.resolve()
_, rules = load_source_rules(config_path, deploy_root)
review_entries = load_review_entries(review_ledger_path, deploy_root)
diff_entries = (
parse_diff_entries(args.changed_files.resolve()) if args.changed_files else []
)
review_all = not diff_entries or control_files_changed(
diff_entries,
{args.config.as_posix(), args.review_ledger.as_posix()},
)
if review_all:
candidate_paths = sorted(
path.relative_to(repo_root).as_posix()
for path in repo_root.rglob("*.md")
if path.is_file()
)
else:
candidate_paths = sorted(
entry.path
for entry in diff_entries
if entry.change_type == "upsert" and entry.path.endswith(".md")
)
blocking_lines: list[str] = []
warning_lines: list[str] = []
passed_lines: list[str] = []
review_rows: list[JSONMapping] = []
for relative_path in candidate_paths:
rule = resolve_rule_for_path(relative_path, rules)
if rule is None:
continue
content = (repo_root / relative_path).read_text(encoding="utf-8")
resolved_review = resolve_document_review(
rule=rule,
relative_path=relative_path,
content=content,
review_entries=review_entries,
)
source_id = require_non_empty_str(getattr(rule, "source_id"), "rule.source_id")
review_mode = require_non_empty_str(
getattr(rule, "review_mode"), "rule.review_mode"
)
blocking_issues = (
resolved_review.standard_issues + resolved_review.review_issues
if review_mode == "required"
else ()
)
warning_issues = (
resolved_review.standard_issues if review_mode == "legacy" else ()
)
if blocking_issues:
blocking_lines.append(
format_issue_line(relative_path, source_id, blocking_issues)
)
elif warning_issues:
warning_lines.append(
format_issue_line(relative_path, source_id, warning_issues)
)
else:
passed_lines.append(f"- `{relative_path}` (`{source_id}`)")
review_rows.append(
{
"source_id": source_id,
"source_path": relative_path,
"review_mode": review_mode,
"review_status": resolved_review.review_status,
"blocking_issues": list(blocking_issues),
"warning_issues": list(warning_issues),
"reason": resolved_review.reason,
}
)
summary: JSONMapping = {
"reviewed_documents": len(review_rows),
"blocking_documents": len(blocking_lines),
"warning_documents": len(warning_lines),
"passed": len(blocking_lines) == 0,
"review_mode": "full" if review_all else "changed",
"documents": review_rows,
}
report = build_report(
reviewed_documents=len(review_rows),
blocking_lines=blocking_lines,
warning_lines=warning_lines,
passed_lines=passed_lines,
)
write_json(summary_path, summary)
report_path.parent.mkdir(parents=True, exist_ok=True)
report_path.write_text(report, encoding="utf-8")
print(json.dumps(summary, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())