From fdda95aea98958c07c0560bd910d80528b2a2f02 Mon Sep 17 00:00:00 2001 From: wantsong Date: Fri, 19 Jun 2026 03:15:05 +0800 Subject: [PATCH] feat: add review bundle audit skill --- docs/install-sync.md | 2 + registry/skills-index.md | 1 + skills/review-bundle-audit/README.md | 41 ++ skills/review-bundle-audit/SKILL.md | 103 +++++ skills/review-bundle-audit/agents/openai.yaml | 4 + .../fixtures/profile.example.yaml | 23 ++ .../scripts/review_bundle_audit.py | 367 ++++++++++++++++++ .../tests/test_review_bundle_audit.py | 196 ++++++++++ 8 files changed, 737 insertions(+) create mode 100644 skills/review-bundle-audit/README.md create mode 100644 skills/review-bundle-audit/SKILL.md create mode 100644 skills/review-bundle-audit/agents/openai.yaml create mode 100644 skills/review-bundle-audit/fixtures/profile.example.yaml create mode 100644 skills/review-bundle-audit/scripts/review_bundle_audit.py create mode 100644 skills/review-bundle-audit/tests/test_review_bundle_audit.py diff --git a/docs/install-sync.md b/docs/install-sync.md index fa3acd8..f85c3c4 100644 --- a/docs/install-sync.md +++ b/docs/install-sync.md @@ -25,6 +25,7 @@ powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill bund powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill lifecycle-status-guard-scan powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill repair-markdown-citations powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill regression-validation-gate-runner +powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill review-bundle-audit ``` Use `-Force` to back up and replace an existing installed copy: @@ -34,6 +35,7 @@ powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill bund powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill lifecycle-status-guard-scan -Force powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill repair-markdown-citations -Force powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill regression-validation-gate-runner -Force +powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill review-bundle-audit -Force ``` `-All` installs only registry rows with an installable status and `default` install policy. Optional Skills must be named explicitly. diff --git a/registry/skills-index.md b/registry/skills-index.md index e06319f..8553e8b 100644 --- a/registry/skills-index.md +++ b/registry/skills-index.md @@ -18,6 +18,7 @@ Existing CCPE content is not migrated here. | `lifecycle-status-guard-scan` | Scan configured Markdown, JSON, YAML, and text files for lifecycle/status overclaim candidates and missing local evidence markers. | `C:\Users\wangq\Documents\Codex\skills-vault\skills\lifecycle-status-guard-scan` | `installed` | `default` | `C:\Users\wangq\.agents\skills\lifecycle-status-guard-scan` | none | | `repair-markdown-citations` | Repair ChatGPT/Deep Research Markdown citation tokens into standard footnotes and a deduplicated reference section using exact turn-ID metadata mapping. | `C:\Users\wangq\Documents\Codex\skills-vault\skills\repair-markdown-citations` | `installed` | `default` | `C:\Users\wangq\.agents\skills\repair-markdown-citations` | none | | `regression-validation-gate-runner` | Dry-run or execute project-declared regression and validation gates from a manifest while capturing logs, exit codes, durations, skipped gates, and changed-file notes. | `C:\Users\wangq\Documents\Codex\skills-vault\skills\regression-validation-gate-runner` | `installed` | `default` | `C:\Users\wangq\.agents\skills\regression-validation-gate-runner` | none | +| `review-bundle-audit` | Preflight review, handoff, release, CCRA, Web upload, or code-review bundle directories for required files, manifests, sidecars, reports, zip readability, warnings, and blockers. | `C:\Users\wangq\Documents\Codex\skills-vault\skills\review-bundle-audit` | `installed` | `default` | `C:\Users\wangq\.agents\skills\review-bundle-audit` | none | | `voice-generation` | Generate spoken audio from Markdown scripts using the local `voice-gen` CLI and MiniMax `mmx`, including custom voice clone registration and voice registry management. | `D:\AI\ClaudeCode\voice-ge` | `installed` | `conditional` | `C:\Users\wangq\.agents\skills\voice-generation` | none | ## Status Values diff --git a/skills/review-bundle-audit/README.md b/skills/review-bundle-audit/README.md new file mode 100644 index 0000000..4d8e07c --- /dev/null +++ b/skills/review-bundle-audit/README.md @@ -0,0 +1,41 @@ +# Review Bundle Audit + +Source Skill for auditing review bundle directories before human or Agent review. + +The canonical entry point is `SKILL.md`; the deterministic auditor is in `scripts/review_bundle_audit.py`. + +## Original Source + +```text +Original source: C:\Users\wangq\Documents\Codex\ccpe-system\requirements\skills-vault\2026-06-19-review-bundle-audit.md +Migration date: 2026-06-19 +Migration status: first public Skill implementation +Behavior preserved: deterministic package audit only; no review judgment or bundle repair +Known gaps: built-in profile is generic and intentionally conservative +``` + +## Layout + +```text +SKILL.md +README.md +agents/openai.yaml +fixtures/profile.example.yaml +scripts/review_bundle_audit.py +tests/test_review_bundle_audit.py +``` + +## Usage + +```powershell +conda run -n skills-vault python .\scripts\review_bundle_audit.py ` + --bundle-root C:\path\review-bundle ` + --output-dir C:\path\helper-outputs ` + --profile generic +``` + +## Tests + +```powershell +conda run -n skills-vault python -B -m unittest discover -s skills/review-bundle-audit/tests -v +``` diff --git a/skills/review-bundle-audit/SKILL.md b/skills/review-bundle-audit/SKILL.md new file mode 100644 index 0000000..3b1d2e7 --- /dev/null +++ b/skills/review-bundle-audit/SKILL.md @@ -0,0 +1,103 @@ +--- +name: review-bundle-audit +description: Use when Codex needs to preflight a review, handoff, release, CCRA, Web upload, article, video, Skill, or code-review bundle directory for required files, manifests, validation sidecars, reports, zip readability, missing materials, extra files, UTF-8 paths, and review-package completeness before a human or Agent review. +--- + +# Review Bundle Audit + +## Purpose + +Audit a review bundle directory before reviewer judgment is spent. Use the bundled script to produce deterministic Markdown and JSON reports about bundle structure, required files, readable manifests, validation sidecars, reports, zips, warnings, and blockers. + +This Skill is a package audit only. It must not repair bundles, edit source files, or decide review acceptance. + +## Command + +```powershell +conda run -n skills-vault python .\scripts\review_bundle_audit.py ` + --bundle-root C:\path\review-bundle ` + --output-dir C:\path\helper-outputs ` + --profile generic +``` + +Use `--profile-config C:\path\profile.yaml` when a project has its own required file names. + +When installed under `C:\Users\wangq\.agents\skills\review-bundle-audit`, run from that Skill directory or use the installed script path. + +## Built-In Profile + +`generic` requires: + +- `brief`: `*brief*.md`, `*brief*.markdown` +- `manifest`: `*manifest*.json`, `*manifest*.yaml`, `*manifest*.yml` +- `validation_sidecar`: `*validation*sidecar*.json`, `*validation*sidecar*.yaml`, `*validation*sidecar*.yml` + +It optionally recognizes: + +- `report`: `*report*.md`, `*report*.markdown` +- `zip`: `*.zip` +- `changed_files`: changed-file summary names + +Extra files produce warnings, not blockers. Missing report or zip produces warnings. Unreadable manifest, sidecar, or zip produces a blocker. + +## Profile Config + +```yaml +profiles: + project-review: + required_files: + brief: + - review-brief.md + manifest: + - review-manifest.json + validation_sidecar: + - validation-sidecar.json + optional_files: + report: + - "*report*.md" + zip: + - "*.zip" + allowed_extra_patterns: + - notes/*.md +``` + +## Outputs + +The script writes under `output_dir`: + +```text +bundle-audit.md +bundle-audit.json +``` + +Reports include: + +- bundle root and profile +- files discovered +- required files present and missing +- optional files present +- manifest status +- zip status +- validation sidecar status +- report status +- warnings and blocking errors +- machine-readable summary + +Exit code is `0` when no blockers exist, `1` for audit blockers, and `2` for input/profile errors. + +## Safety + +- Read only the specified bundle directory and optional profile config. +- Write only audit reports under `output_dir`. +- Do not create, repair, normalize, or delete bundle files. +- Do not edit model cards, selector rules, regression cases, project docs, or source files. +- Treat the audit as evidence for a reviewer, not as review acceptance. + +## Validation + +After changing audit behavior, run: + +```powershell +conda run -n skills-vault python -B -m unittest discover -s skills/review-bundle-audit/tests -v +conda run -n skills-vault python -B C:\Users\wangq\.codex\skills\.system\skill-creator\scripts\quick_validate.py skills/review-bundle-audit +``` diff --git a/skills/review-bundle-audit/agents/openai.yaml b/skills/review-bundle-audit/agents/openai.yaml new file mode 100644 index 0000000..5a8cc6c --- /dev/null +++ b/skills/review-bundle-audit/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Review Bundle Audit" + short_description: "Preflight review bundle completeness" + default_prompt: "Use $review-bundle-audit to preflight a review bundle and write Markdown and JSON audit reports." diff --git a/skills/review-bundle-audit/fixtures/profile.example.yaml b/skills/review-bundle-audit/fixtures/profile.example.yaml new file mode 100644 index 0000000..a68b9c5 --- /dev/null +++ b/skills/review-bundle-audit/fixtures/profile.example.yaml @@ -0,0 +1,23 @@ +profiles: + project-review: + required_files: + brief: + - review-brief.md + - "*brief*.md" + manifest: + - review-manifest.json + - "*manifest*.json" + validation_sidecar: + - validation-sidecar.json + - "*validation*sidecar*.json" + optional_files: + report: + - "*report*.md" + zip: + - "*.zip" + changed_files: + - "*changed*files*.md" + - "*changed-files*.json" + allowed_extra_patterns: + - notes/*.md + - README.md diff --git a/skills/review-bundle-audit/scripts/review_bundle_audit.py b/skills/review-bundle-audit/scripts/review_bundle_audit.py new file mode 100644 index 0000000..32de096 --- /dev/null +++ b/skills/review-bundle-audit/scripts/review_bundle_audit.py @@ -0,0 +1,367 @@ +from __future__ import annotations + +import argparse +import fnmatch +import json +import pathlib +import sys +import zipfile +from collections.abc import Iterable, Mapping +from typing import Any + +import yaml + + +REPORT_JSON = "bundle-audit.json" +REPORT_MD = "bundle-audit.md" + + +BUILTIN_PROFILES: dict[str, dict[str, Any]] = { + "generic": { + "required_files": { + "brief": ["*brief*.md", "*brief*.markdown"], + "manifest": ["*manifest*.json", "*manifest*.yaml", "*manifest*.yml"], + "validation_sidecar": [ + "*validation*sidecar*.json", + "*validation*sidecar*.yaml", + "*validation*sidecar*.yml", + ], + }, + "optional_files": { + "report": ["*report*.md", "*report*.markdown"], + "zip": ["*.zip"], + "changed_files": ["*changed*files*.md", "*changed-files*.json", "*changed*files*.json"], + }, + "allowed_extra_patterns": [], + } +} + + +def as_list(value: Any) -> list[str]: + if value is None: + return [] + if isinstance(value, str): + return [value] + if isinstance(value, Iterable): + return [str(item) for item in value if item is not None] + return [str(value)] + + +def load_structured(path: pathlib.Path) -> Any: + text = path.read_text(encoding="utf-8") + suffix = path.suffix.casefold() + if suffix == ".json": + return json.loads(text) + if suffix in {".yaml", ".yml"}: + return yaml.safe_load(text) + return text + + +def load_profile_config(path: pathlib.Path | None) -> dict[str, Any]: + if path is None: + return {} + parsed = load_structured(path) + if not isinstance(parsed, Mapping): + raise ValueError("Profile config must be a JSON or YAML object.") + return dict(parsed) + + +def get_profile(profile_name: str, config: Mapping[str, Any]) -> dict[str, Any]: + profiles = dict(BUILTIN_PROFILES) + configured_profiles = config.get("profiles") if isinstance(config, Mapping) else None + if isinstance(configured_profiles, Mapping): + for name, profile in configured_profiles.items(): + if isinstance(profile, Mapping): + profiles[str(name)] = dict(profile) + if profile_name not in profiles: + raise ValueError(f"Unknown profile: {profile_name}") + return normalize_profile(profiles[profile_name]) + + +def normalize_pattern_map(raw: Any) -> dict[str, list[str]]: + if not isinstance(raw, Mapping): + return {} + return {str(key): as_list(value) for key, value in raw.items()} + + +def normalize_profile(raw_profile: Mapping[str, Any]) -> dict[str, Any]: + return { + "required_files": normalize_pattern_map(raw_profile.get("required_files")), + "optional_files": normalize_pattern_map(raw_profile.get("optional_files")), + "allowed_extra_patterns": as_list(raw_profile.get("allowed_extra_patterns")), + } + + +def rel_path(path: pathlib.Path, root: pathlib.Path) -> str: + return path.relative_to(root).as_posix() + + +def discover_files(bundle_root: pathlib.Path) -> list[pathlib.Path]: + return sorted(path for path in bundle_root.rglob("*") if path.is_file()) + + +def match_patterns(file_rel: str, patterns: list[str]) -> bool: + basename = pathlib.PurePosixPath(file_rel).name + return any(fnmatch.fnmatch(file_rel, pattern) or fnmatch.fnmatch(basename, pattern) for pattern in patterns) + + +def files_for_patterns(files_rel: list[str], patterns: list[str]) -> list[str]: + return sorted(file_rel for file_rel in files_rel if match_patterns(file_rel, patterns)) + + +def status_for_category(files: list[str], missing_status: str = "missing") -> dict[str, Any]: + return {"status": "present" if files else missing_status, "files": files} + + +def inspect_structured_files( + bundle_root: pathlib.Path, + file_rels: list[str], + missing_status: str = "missing", +) -> dict[str, Any]: + if not file_rels: + return {"status": missing_status, "files": []} + errors: list[str] = [] + for file_rel in file_rels: + try: + load_structured(bundle_root / file_rel) + except Exception as exc: + errors.append(f"{file_rel}: {exc}") + if errors: + return {"status": "unreadable", "files": file_rels, "errors": errors} + return {"status": "present", "files": file_rels} + + +def inspect_zip_files(bundle_root: pathlib.Path, zip_rels: list[str]) -> dict[str, Any]: + if not zip_rels: + return {"status": "missing", "files": []} + errors: list[str] = [] + entry_counts: dict[str, int] = {} + for file_rel in zip_rels: + path = bundle_root / file_rel + try: + with zipfile.ZipFile(path) as archive: + bad_member = archive.testzip() + if bad_member is not None: + errors.append(f"{file_rel}: unreadable member {bad_member}") + entry_counts[file_rel] = len(archive.namelist()) + except Exception as exc: + errors.append(f"{file_rel}: {exc}") + if errors: + return {"status": "unreadable", "files": zip_rels, "errors": errors, "entry_counts": entry_counts} + return {"status": "readable", "files": zip_rels, "entry_counts": entry_counts} + + +def category_matches(profile: Mapping[str, Any], files_rel: list[str]) -> tuple[dict[str, list[str]], dict[str, list[str]]]: + required_matches = { + category: files_for_patterns(files_rel, patterns) + for category, patterns in profile["required_files"].items() + } + optional_matches = { + category: files_for_patterns(files_rel, patterns) + for category, patterns in profile["optional_files"].items() + } + return required_matches, optional_matches + + +def allowed_patterns(profile: Mapping[str, Any]) -> list[str]: + patterns: list[str] = [] + for pattern_map_name in ("required_files", "optional_files"): + for category_patterns in profile[pattern_map_name].values(): + patterns.extend(category_patterns) + patterns.extend(profile.get("allowed_extra_patterns", [])) + return patterns + + +def extra_files(files_rel: list[str], profile: Mapping[str, Any]) -> list[str]: + patterns = allowed_patterns(profile) + return [file_rel for file_rel in files_rel if not match_patterns(file_rel, patterns)] + + +def empty_report(bundle_root: pathlib.Path, profile_name: str) -> dict[str, Any]: + return { + "bundle_root": str(bundle_root), + "profile": profile_name, + "files_discovered": [], + "required_files_present": {}, + "required_files_missing": [], + "optional_files_present": {}, + "manifest_status": {"status": "missing", "files": []}, + "zip_status": {"status": "missing", "files": []}, + "validation_sidecar_status": {"status": "missing", "files": []}, + "report_status": {"status": "missing", "files": []}, + "warnings": [], + "blocking_errors": [], + "machine_readable_summary": {}, + } + + +def audit_bundle(bundle_root: pathlib.Path, profile_name: str, profile: Mapping[str, Any]) -> dict[str, Any]: + report = empty_report(bundle_root, profile_name) + files = discover_files(bundle_root) + files_rel = [rel_path(path, bundle_root) for path in files] + report["files_discovered"] = files_rel + + required_matches, optional_matches = category_matches(profile, files_rel) + report["required_files_present"] = { + category: matches for category, matches in required_matches.items() if matches + } + report["optional_files_present"] = { + category: matches for category, matches in optional_matches.items() if matches + } + + for category, matches in required_matches.items(): + if not matches: + report["required_files_missing"].append(category) + report["blocking_errors"].append(f"Missing required {category} file.") + + manifest_files = required_matches.get("manifest", []) + sidecar_files = required_matches.get("validation_sidecar", []) + report_files = optional_matches.get("report", []) + zip_files = optional_matches.get("zip", []) + + report["manifest_status"] = inspect_structured_files(bundle_root, manifest_files) + report["validation_sidecar_status"] = inspect_structured_files(bundle_root, sidecar_files) + report["report_status"] = status_for_category(report_files) + report["zip_status"] = inspect_zip_files(bundle_root, zip_files) + + if report["manifest_status"]["status"] == "unreadable": + report["blocking_errors"].append("Manifest file is unreadable.") + if report["validation_sidecar_status"]["status"] == "unreadable": + report["blocking_errors"].append("Validation sidecar file is unreadable.") + if report["zip_status"]["status"] == "unreadable": + report["blocking_errors"].append("Zip file is unreadable.") + if not zip_files: + report["warnings"].append("No zip file matched the profile.") + if not report_files: + report["warnings"].append("No review report file matched the profile.") + + for file_rel in extra_files(files_rel, profile): + report["warnings"].append(f"Extra file not matched by profile: {file_rel}") + + report["required_files_missing"] = sorted(report["required_files_missing"]) + report["warnings"] = sorted(report["warnings"]) + report["blocking_errors"] = sorted(set(report["blocking_errors"])) + finalize_report(report) + return report + + +def finalize_report(report: dict[str, Any]) -> None: + status = "FAIL" if report["blocking_errors"] else "PASS" + report["machine_readable_summary"] = { + "status": status, + "files_discovered_count": len(report["files_discovered"]), + "required_missing_count": len(report["required_files_missing"]), + "warning_count": len(report["warnings"]), + "blocking_error_count": len(report["blocking_errors"]), + "manifest_status": report["manifest_status"]["status"], + "zip_status": report["zip_status"]["status"], + "validation_sidecar_status": report["validation_sidecar_status"]["status"], + "report_status": report["report_status"]["status"], + } + + +def write_json_report(report: Mapping[str, Any], output_dir: pathlib.Path) -> None: + (output_dir / REPORT_JSON).write_text( + json.dumps(report, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + +def write_markdown_report(report: Mapping[str, Any], output_dir: pathlib.Path) -> None: + summary = report["machine_readable_summary"] + lines = [ + "# Review Bundle Audit", + "", + "## Summary", + "", + f"- Status: `{summary['status']}`", + f"- Bundle root: `{report['bundle_root']}`", + f"- Profile: `{report['profile']}`", + f"- Files discovered: {summary['files_discovered_count']}", + f"- Required files missing: {summary['required_missing_count']}", + f"- Warnings: {summary['warning_count']}", + f"- Blocking errors: {summary['blocking_error_count']}", + "", + "## Required Files", + "", + ] + for category, files in report["required_files_present"].items(): + lines.append(f"- `{category}`: {', '.join(f'`{file}`' for file in files)}") + if report["required_files_missing"]: + lines.append("") + lines.append("Missing:") + for category in report["required_files_missing"]: + lines.append(f"- `{category}`") + lines.extend(["", "## Package Status", ""]) + for key in ("manifest_status", "validation_sidecar_status", "zip_status", "report_status"): + status = report[key]["status"] + files = report[key].get("files", []) + rendered_files = ", ".join(f"`{file}`" for file in files) if files else "none" + lines.append(f"- `{key}`: `{status}` ({rendered_files})") + lines.extend(["", "## Blocking Errors", ""]) + if report["blocking_errors"]: + for error in report["blocking_errors"]: + lines.append(f"- {error}") + else: + lines.append("- None") + lines.extend(["", "## Warnings", ""]) + if report["warnings"]: + for warning in report["warnings"]: + lines.append(f"- {warning}") + else: + lines.append("- None") + lines.extend(["", "## Files Discovered", ""]) + for file_rel in report["files_discovered"]: + lines.append(f"- `{file_rel}`") + (output_dir / REPORT_MD).write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def write_reports(report: Mapping[str, Any], output_dir: pathlib.Path) -> None: + output_dir.mkdir(parents=True, exist_ok=True) + write_json_report(report, output_dir) + write_markdown_report(report, output_dir) + + +def run( + bundle_root: pathlib.Path, + output_dir: pathlib.Path, + profile_name: str, + profile_config: pathlib.Path | None, +) -> int: + bundle_root = bundle_root.resolve() + output_dir = output_dir.resolve() + output_dir.mkdir(parents=True, exist_ok=True) + report = empty_report(bundle_root, profile_name) + + if not bundle_root.is_dir(): + report["blocking_errors"].append(f"Bundle root is not a directory: {bundle_root}") + finalize_report(report) + write_reports(report, output_dir) + return 2 + + try: + config = load_profile_config(profile_config) + profile = get_profile(profile_name, config) + report = audit_bundle(bundle_root, profile_name, profile) + except Exception as exc: + report["blocking_errors"].append(str(exc)) + finalize_report(report) + write_reports(report, output_dir) + return 2 + + write_reports(report, output_dir) + return 1 if report["blocking_errors"] else 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Audit a review bundle directory.") + parser.add_argument("--bundle-root", required=True, type=pathlib.Path) + parser.add_argument("--output-dir", required=True, type=pathlib.Path) + parser.add_argument("--profile", default="generic") + parser.add_argument("--profile-config", type=pathlib.Path) + args = parser.parse_args(argv) + return run(args.bundle_root, args.output_dir, args.profile, args.profile_config) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/review-bundle-audit/tests/test_review_bundle_audit.py b/skills/review-bundle-audit/tests/test_review_bundle_audit.py new file mode 100644 index 0000000..ee6b559 --- /dev/null +++ b/skills/review-bundle-audit/tests/test_review_bundle_audit.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +import json +import pathlib +import shutil +import subprocess +import sys +import tempfile +import unittest +import zipfile + + +SKILL_ROOT = pathlib.Path(__file__).resolve().parents[1] +REPO_ROOT = pathlib.Path(__file__).resolve().parents[3] +SCRIPT = SKILL_ROOT / "scripts" / "review_bundle_audit.py" +TMP_ROOT = REPO_ROOT / "tmp" / "review-bundle-audit-tests" + + +def run_cli(*args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, str(SCRIPT), *args], + text=True, + capture_output=True, + check=False, + ) + + +def write_text(path: pathlib.Path, text: str = "content") -> pathlib.Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return path + + +def write_json(path: pathlib.Path, data: object) -> pathlib.Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + return path + + +def write_zip(path: pathlib.Path) -> pathlib.Path: + path.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(path, "w") as archive: + archive.writestr("included.txt", "ok") + return path + + +def load_report(output_dir: pathlib.Path) -> dict[str, object]: + return json.loads((output_dir / "bundle-audit.json").read_text(encoding="utf-8")) + + +class ReviewBundleAuditTest(unittest.TestCase): + def setUp(self) -> None: + TMP_ROOT.mkdir(parents=True, exist_ok=True) + self.tmp_dir = tempfile.TemporaryDirectory(dir=TMP_ROOT) + self.bundle_root = pathlib.Path(self.tmp_dir.name) / "ccra_review_bundle" / "round-05" + self.output_dir = pathlib.Path(self.tmp_dir.name) / "outputs" + self.bundle_root.mkdir(parents=True) + + def tearDown(self) -> None: + self.tmp_dir.cleanup() + + @classmethod + def tearDownClass(cls) -> None: + if TMP_ROOT.exists(): + shutil.rmtree(TMP_ROOT) + + def write_complete_bundle(self) -> None: + write_text(self.bundle_root / "review-brief.md", "# Brief\n") + write_json(self.bundle_root / "review-manifest.json", {"files": ["review-brief.md"]}) + write_json(self.bundle_root / "validation-sidecar.json", {"status": "pass"}) + write_text(self.bundle_root / "local-review-report.md", "# Report\n") + write_text(self.bundle_root / "changed-files.md", "- src/model.json\n") + write_zip(self.bundle_root / "raw-review-bundle.zip") + + def run_audit(self, *extra_args: str) -> tuple[subprocess.CompletedProcess[str], dict[str, object]]: + result = run_cli( + "--bundle-root", + str(self.bundle_root), + "--output-dir", + str(self.output_dir), + *extra_args, + ) + return result, load_report(self.output_dir) + + def test_complete_bundle_passes_and_reports_present_files(self) -> None: + self.write_complete_bundle() + + result, report = self.run_audit() + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(report["machine_readable_summary"]["status"], "PASS") + self.assertEqual(report["required_files_missing"], []) + self.assertEqual(report["manifest_status"]["status"], "present") + self.assertEqual(report["zip_status"]["status"], "readable") + self.assertEqual(report["validation_sidecar_status"]["status"], "present") + self.assertEqual(report["report_status"]["status"], "present") + self.assertIn("review-brief.md", report["files_discovered"]) + + def test_missing_required_brief_is_blocking(self) -> None: + self.write_complete_bundle() + (self.bundle_root / "review-brief.md").unlink() + + result, report = self.run_audit() + + self.assertEqual(result.returncode, 1, result.stderr) + self.assertEqual(report["machine_readable_summary"]["status"], "FAIL") + self.assertIn("brief", report["required_files_missing"]) + self.assertTrue(any("brief" in error for error in report["blocking_errors"])) + + def test_missing_manifest_is_blocking(self) -> None: + self.write_complete_bundle() + (self.bundle_root / "review-manifest.json").unlink() + + result, report = self.run_audit() + + self.assertEqual(result.returncode, 1, result.stderr) + self.assertEqual(report["manifest_status"]["status"], "missing") + self.assertIn("manifest", report["required_files_missing"]) + + def test_missing_validation_sidecar_is_blocking(self) -> None: + self.write_complete_bundle() + (self.bundle_root / "validation-sidecar.json").unlink() + + result, report = self.run_audit() + + self.assertEqual(result.returncode, 1, result.stderr) + self.assertEqual(report["validation_sidecar_status"]["status"], "missing") + self.assertIn("validation_sidecar", report["required_files_missing"]) + + def test_unreadable_zip_is_blocking_when_zip_is_present(self) -> None: + self.write_complete_bundle() + write_text(self.bundle_root / "raw-review-bundle.zip", "not a real zip") + + result, report = self.run_audit() + + self.assertEqual(result.returncode, 1, result.stderr) + self.assertEqual(report["zip_status"]["status"], "unreadable") + self.assertTrue(any("zip" in error.casefold() for error in report["blocking_errors"])) + + def test_extra_files_are_warnings_not_blockers(self) -> None: + self.write_complete_bundle() + write_text(self.bundle_root / "scratch.tmp", "operator note") + + result, report = self.run_audit() + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(report["machine_readable_summary"]["status"], "PASS") + self.assertEqual(report["blocking_errors"], []) + self.assertTrue(any("scratch.tmp" in warning for warning in report["warnings"])) + + def test_utf8_paths_and_chinese_filenames_are_reported(self) -> None: + self.bundle_root = pathlib.Path(self.tmp_dir.name) / "审查包" + self.bundle_root.mkdir() + write_text(self.bundle_root / "审查-brief.md", "# Brief\n") + write_json(self.bundle_root / "审查-manifest.json", {"files": ["审查-brief.md"]}) + write_json(self.bundle_root / "验证-validation-sidecar.json", {"status": "pass"}) + write_text(self.bundle_root / "审查-report.md", "# Report\n") + write_zip(self.bundle_root / "原始-review-bundle.zip") + + result, report = self.run_audit() + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("审查-brief.md", report["files_discovered"]) + self.assertEqual(report["zip_status"]["files"][0], "原始-review-bundle.zip") + + def test_profile_config_can_define_custom_required_patterns(self) -> None: + profile_config = pathlib.Path(self.tmp_dir.name) / "profile.json" + write_text(self.bundle_root / "handoff.txt", "handoff") + write_json(self.bundle_root / "index.json", {"files": ["handoff.txt"]}) + write_json(self.bundle_root / "checks.json", {"status": "pass"}) + write_json( + profile_config, + { + "profiles": { + "custom": { + "required_files": { + "brief": ["handoff.txt"], + "manifest": ["index.json"], + "validation_sidecar": ["checks.json"], + }, + "optional_files": {"report": ["notes.md"], "zip": ["*.zip"]}, + "allowed_extra_patterns": ["handoff.txt", "index.json", "checks.json"], + } + } + }, + ) + + result, report = self.run_audit("--profile", "custom", "--profile-config", str(profile_config)) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(report["profile"], "custom") + self.assertEqual(report["required_files_missing"], []) + + +if __name__ == "__main__": + unittest.main()