diff --git a/docs/install-sync.md b/docs/install-sync.md index f85c3c4..d92dcb1 100644 --- a/docs/install-sync.md +++ b/docs/install-sync.md @@ -26,6 +26,7 @@ powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill life 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 +powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill review-context-builder ``` Use `-Force` to back up and replace an existing installed copy: @@ -36,6 +37,7 @@ powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill life 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 +powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill review-context-builder -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 8553e8b..379292a 100644 --- a/registry/skills-index.md +++ b/registry/skills-index.md @@ -19,6 +19,7 @@ Existing CCPE content is not migrated here. | `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 | +| `review-context-builder` | Build file-first review, audit, planning, release, handoff, PR, or Agent invocation context indexes and manifests from configured local source roots, patterns, and metadata. | `C:\Users\wangq\Documents\Codex\skills-vault\skills\review-context-builder` | `installed` | `default` | `C:\Users\wangq\.agents\skills\review-context-builder` | 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-context-builder/README.md b/skills/review-context-builder/README.md new file mode 100644 index 0000000..9a31fac --- /dev/null +++ b/skills/review-context-builder/README.md @@ -0,0 +1,43 @@ +# Review Context Builder + +Source Skill for generating file-first review context indexes and manifests. + +The canonical entry point is `SKILL.md`; the deterministic builder is in `scripts/review_context_builder.py`. + +## Original Source + +```text +Original source: C:\Users\wangq\Documents\Codex\ccpe-system\requirements\skills-vault\2026-06-19-review-context-builder.md +Migration date: 2026-06-19 +Migration status: first public Skill implementation +Behavior preserved: deterministic context indexing only; no review judgment or source evidence replacement +Known gaps: categorization uses filename/path heuristics only +``` + +## Layout + +```text +SKILL.md +README.md +agents/openai.yaml +fixtures/metadata.example.json +scripts/review_context_builder.py +tests/test_review_context_builder.py +``` + +## Usage + +```powershell +conda run -n skills-vault python .\scripts\review_context_builder.py ` + --project-root C:\path\project ` + --review-id review-001 ` + --source-root . ` + --include-pattern "**/*.md" ` + --output-dir C:\path\helper-outputs +``` + +## Tests + +```powershell +conda run -n skills-vault python -B -m unittest discover -s skills/review-context-builder/tests -v +``` diff --git a/skills/review-context-builder/SKILL.md b/skills/review-context-builder/SKILL.md new file mode 100644 index 0000000..828632b --- /dev/null +++ b/skills/review-context-builder/SKILL.md @@ -0,0 +1,87 @@ +--- +name: review-context-builder +description: Use when Codex needs to build a file-first review, audit, planning, synthesis, release, handoff, CCRA, article, video, Skill, model-card, PR, or agent-invocation context package from configured local files, reports, diffs, validation outputs, prior decisions, metadata, include/exclude patterns, and source roots. +--- + +# Review Context Builder + +## Purpose + +Build a deterministic context index and file manifest before reviewer or Agent invocation. Use the bundled script to identify the files a reviewer should read, categorize references, pass metadata through, and record missing or excluded sources. + +This Skill creates navigation context only. It must not summarize source evidence in a way that replaces file reading, make findings, create bundles, or decide readiness. + +## Command + +```powershell +conda run -n skills-vault python .\scripts\review_context_builder.py ` + --project-root C:\path\project ` + --review-id round-05-local-pass ` + --source-root README.md ` + --source-root reports ` + --include-pattern "**/*.md" ` + --exclude-pattern "archive/**" ` + --metadata C:\path\metadata.json ` + --output-dir C:\path\helper-outputs +``` + +When installed under `C:\Users\wangq\.agents\skills\review-context-builder`, run from that Skill directory or use the installed script path. + +## Inputs + +- `--project-root`: root used for path normalization and source-root containment. +- `--review-id`: stable ID for the review or handoff context. +- `--context-profile`: optional label, default `generic`. +- `--source-root`: repeatable file or directory, relative to `project_root` or absolute under it. +- `--include-pattern`: repeatable glob, default `**/*`. +- `--exclude-pattern`: repeatable glob for files to record as excluded. +- `--metadata`: optional JSON/YAML object; passed through without hard-coded CCRA fields. +- `--max-file-bytes`: files larger than this are excluded and recorded. + +## Outputs + +The script writes under `output_dir`: + +```text +review-context.md +review-file-manifest.json +``` + +Outputs include: + +- review ID and context profile +- source roots and missing source roots +- files included and excluded +- important report references +- test or validation output references +- diff or changed-file references +- prior decision references +- known non-goals and open questions from metadata +- original metadata passthrough +- machine-readable summary + +## Rules + +- Normalize included paths as project-relative POSIX paths. +- Keep output ordering stable. +- Treat missing source roots as blockers while still writing both output files. +- Exclude large files instead of embedding content. +- Do not read or quote source file bodies for context prose. +- Do not inject project-specific fields unless they came from metadata. + +## Safety + +- Read only configured source roots and optional metadata file. +- Write only `review-context.md` and `review-file-manifest.json` under `output_dir`. +- Do not edit project files. +- Do not create review bundles. +- Do not decide review acceptance, round closure, lifecycle status, release readiness, or task approval. + +## Validation + +After changing builder behavior, run: + +```powershell +conda run -n skills-vault python -B -m unittest discover -s skills/review-context-builder/tests -v +conda run -n skills-vault python -B C:\Users\wangq\.codex\skills\.system\skill-creator\scripts\quick_validate.py skills/review-context-builder +``` diff --git a/skills/review-context-builder/agents/openai.yaml b/skills/review-context-builder/agents/openai.yaml new file mode 100644 index 0000000..057c60c --- /dev/null +++ b/skills/review-context-builder/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Review Context Builder" + short_description: "Build file-first review context indexes" + default_prompt: "Use $review-context-builder to create a review-context.md and review-file-manifest.json from configured local evidence files." diff --git a/skills/review-context-builder/fixtures/metadata.example.json b/skills/review-context-builder/fixtures/metadata.example.json new file mode 100644 index 0000000..4d57325 --- /dev/null +++ b/skills/review-context-builder/fixtures/metadata.example.json @@ -0,0 +1,12 @@ +{ + "review_goal": "Check whether the supplied evidence is ready for reviewer judgment.", + "non_goals": [ + "Do not decide approval.", + "Do not replace reading source files." + ], + "open_questions": [ + "Are required evidence files present?", + "Are validation outputs current?" + ], + "target_reviewer": "reviewer-or-agent-name" +} diff --git a/skills/review-context-builder/scripts/review_context_builder.py b/skills/review-context-builder/scripts/review_context_builder.py new file mode 100644 index 0000000..d1ee650 --- /dev/null +++ b/skills/review-context-builder/scripts/review_context_builder.py @@ -0,0 +1,371 @@ +from __future__ import annotations + +import argparse +import fnmatch +import json +import pathlib +from collections.abc import Mapping +from typing import Any + +import yaml + + +CONTEXT_MD = "review-context.md" +MANIFEST_JSON = "review-file-manifest.json" +DEFAULT_MAX_FILE_BYTES = 1_000_000 + + +def as_list(value: Any) -> list[str]: + if value is None: + return [] + if isinstance(value, list): + return [str(item) for item in value] + return [str(value)] + + +def load_metadata(path: pathlib.Path | None) -> dict[str, Any]: + if path is None: + return {} + text = path.read_text(encoding="utf-8") + if path.suffix.casefold() == ".json": + parsed = json.loads(text) + else: + parsed = yaml.safe_load(text) + if parsed is None: + return {} + if not isinstance(parsed, Mapping): + raise ValueError("Metadata must be a JSON or YAML object.") + return dict(parsed) + + +def rel_path(path: pathlib.Path, project_root: pathlib.Path) -> str: + return path.relative_to(project_root).as_posix() + + +def resolve_source_root(raw_source: str, project_root: pathlib.Path) -> pathlib.Path: + source = pathlib.Path(raw_source) + if not source.is_absolute(): + source = project_root / source + return source.resolve() + + +def normalize_source_root(raw_source: str, project_root: pathlib.Path) -> str: + source = resolve_source_root(raw_source, project_root) + try: + return rel_path(source, project_root) + except ValueError: + return str(source) + + +def path_matches(path: str, patterns: list[str]) -> bool: + if not patterns: + return True + basename = pathlib.PurePosixPath(path).name + for pattern in patterns: + root_pattern = pattern[3:] if pattern.startswith("**/") else None + if fnmatch.fnmatch(path, pattern) or fnmatch.fnmatch(basename, pattern): + return True + if root_pattern and (fnmatch.fnmatch(path, root_pattern) or fnmatch.fnmatch(basename, root_pattern)): + return True + return False + + +def iter_source_files(source: pathlib.Path) -> list[pathlib.Path]: + if source.is_file(): + return [source] + if source.is_dir(): + return sorted(path for path in source.rglob("*") if path.is_file()) + return [] + + +def empty_manifest( + project_root: pathlib.Path, + review_id: str, + context_profile: str, + source_roots: list[str], + metadata: dict[str, Any], +) -> dict[str, Any]: + return { + "review_id": review_id, + "context_profile": context_profile, + "project_root": str(project_root), + "source_roots": source_roots, + "missing_source_roots": [], + "files_included": [], + "files_excluded": [], + "important_reports": [], + "test_or_validation_outputs": [], + "diff_or_changed_file_references": [], + "prior_decision_references": [], + "known_non_goals": as_list(metadata.get("known_non_goals", metadata.get("non_goals"))), + "open_questions_for_reviewer_or_agent": as_list( + metadata.get("open_questions_for_reviewer_or_agent", metadata.get("open_questions")) + ), + "metadata": metadata, + "blocking_errors": [], + "machine_readable_summary": {}, + } + + +def classify_paths(included_paths: list[str]) -> dict[str, list[str]]: + important_reports: list[str] = [] + validation_outputs: list[str] = [] + diffs: list[str] = [] + decisions: list[str] = [] + + for path in included_paths: + folded = path.casefold() + name = pathlib.PurePosixPath(path).name.casefold() + if any(token in folded for token in ("report", "audit", "review")): + important_reports.append(path) + if any(token in folded for token in ("test", "validation", "gate", "log")): + validation_outputs.append(path) + if any(token in folded for token in ("diff", "changed-file", "changed_files", "changed-files")): + diffs.append(path) + if any(token in folded for token in ("decision", "owner-decision", "approval")) or "decision" in name: + decisions.append(path) + + return { + "important_reports": sorted(set(important_reports)), + "test_or_validation_outputs": sorted(set(validation_outputs)), + "diff_or_changed_file_references": sorted(set(diffs)), + "prior_decision_references": sorted(set(decisions)), + } + + +def build_manifest( + project_root: pathlib.Path, + review_id: str, + context_profile: str, + source_roots_raw: list[str], + include_patterns: list[str], + exclude_patterns: list[str], + metadata: dict[str, Any], + max_file_bytes: int, +) -> dict[str, Any]: + normalized_source_roots = [ + normalize_source_root(raw_source, project_root) for raw_source in source_roots_raw + ] + manifest = empty_manifest(project_root, review_id, context_profile, normalized_source_roots, metadata) + seen: dict[str, pathlib.Path] = {} + + for raw_source in source_roots_raw: + source = resolve_source_root(raw_source, project_root) + normalized = normalize_source_root(raw_source, project_root) + if not source.exists(): + manifest["missing_source_roots"].append(normalized) + manifest["blocking_errors"].append(f"Missing source root: {normalized}") + continue + try: + source.relative_to(project_root) + except ValueError: + manifest["missing_source_roots"].append(normalized) + manifest["blocking_errors"].append(f"Source root is outside project_root: {normalized}") + continue + for file_path in iter_source_files(source): + rel = rel_path(file_path.resolve(), project_root) + seen[rel] = file_path + + included: list[dict[str, Any]] = [] + excluded: list[dict[str, Any]] = [] + for file_rel in sorted(seen): + file_path = seen[file_rel] + if not path_matches(file_rel, include_patterns): + continue + if exclude_patterns and path_matches(file_rel, exclude_patterns): + excluded.append({"path": file_rel, "reason": "exclude_pattern"}) + continue + size = file_size_bytes(file_path) + if size > max_file_bytes: + excluded.append({"path": file_rel, "reason": "size_exceeds_limit", "size_bytes": size}) + continue + source_root = source_root_for_file(file_path, source_roots_raw, project_root) + included.append({"path": file_rel, "size_bytes": size, "source_root": source_root}) + + manifest["files_included"] = included + manifest["files_excluded"] = sorted(excluded, key=lambda item: item["path"]) + categories = classify_paths([item["path"] for item in included]) + manifest.update(categories) + finalize_manifest(manifest) + return manifest + + +def source_root_for_file(file_path: pathlib.Path, source_roots_raw: list[str], project_root: pathlib.Path) -> str: + file_path = file_path.resolve() + best: tuple[int, str] | None = None + for raw_source in source_roots_raw: + source = resolve_source_root(raw_source, project_root) + if source.is_file() and source == file_path: + root_rel = normalize_source_root(raw_source, project_root) + score = len(pathlib.PurePosixPath(root_rel).parts) + if best is None or score > best[0]: + best = (score, root_rel) + elif source.is_dir(): + try: + file_path.relative_to(source) + except ValueError: + continue + root_rel = normalize_source_root(raw_source, project_root) + score = len(pathlib.PurePosixPath(root_rel).parts) + if best is None or score > best[0]: + best = (score, root_rel) + return "." if best is None or best[1] == "" else best[1] + + +def file_size_bytes(path: pathlib.Path) -> int: + return len(path.read_text(encoding="utf-8", errors="replace").encode("utf-8")) + + +def finalize_manifest(manifest: dict[str, Any]) -> None: + status = "FAIL" if manifest["blocking_errors"] else "PASS" + manifest["machine_readable_summary"] = { + "status": status, + "source_roots_count": len(manifest["source_roots"]), + "missing_source_roots_count": len(manifest["missing_source_roots"]), + "files_included_count": len(manifest["files_included"]), + "files_excluded_count": len(manifest["files_excluded"]), + "important_reports_count": len(manifest["important_reports"]), + "validation_outputs_count": len(manifest["test_or_validation_outputs"]), + "diff_references_count": len(manifest["diff_or_changed_file_references"]), + "prior_decisions_count": len(manifest["prior_decision_references"]), + "blocking_error_count": len(manifest["blocking_errors"]), + } + + +def write_manifest(manifest: dict[str, Any], output_dir: pathlib.Path) -> None: + (output_dir / MANIFEST_JSON).write_text( + json.dumps(manifest, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + +def write_context(manifest: dict[str, Any], output_dir: pathlib.Path) -> None: + lines = [ + "# Review Context", + "", + "This context is an index. It does not replace reading source files.", + "", + "## Metadata", + "", + f"- Review ID: `{manifest['review_id']}`", + f"- Context profile: `{manifest['context_profile']}`", + f"- Status: `{manifest['machine_readable_summary']['status']}`", + "", + "## Source Roots", + "", + ] + for source in manifest["source_roots"]: + lines.append(f"- `{source}`") + if manifest["missing_source_roots"]: + lines.extend(["", "## Missing Source Roots", ""]) + for source in manifest["missing_source_roots"]: + lines.append(f"- `{source}`") + lines.extend(["", "## Files Included", ""]) + if manifest["files_included"]: + for item in manifest["files_included"]: + lines.append(f"- `{item['path']}` ({item['size_bytes']} bytes)") + else: + lines.append("- None") + lines.extend(["", "## Files Excluded", ""]) + if manifest["files_excluded"]: + for item in manifest["files_excluded"]: + detail = f", {item['size_bytes']} bytes" if "size_bytes" in item else "" + lines.append(f"- `{item['path']}`: {item['reason']}{detail}") + else: + lines.append("- None") + for title, key in [ + ("Important Reports", "important_reports"), + ("Test Or Validation Outputs", "test_or_validation_outputs"), + ("Diff Or Changed File References", "diff_or_changed_file_references"), + ("Prior Decision References", "prior_decision_references"), + ("Known Non Goals", "known_non_goals"), + ("Open Questions For Reviewer Or Agent", "open_questions_for_reviewer_or_agent"), + ]: + lines.extend(["", f"## {title}", ""]) + values = manifest[key] + if values: + for value in values: + lines.append(f"- `{value}`" if isinstance(value, str) else f"- {value}") + else: + lines.append("- None") + lines.extend(["", "## Blocking Errors", ""]) + if manifest["blocking_errors"]: + for error in manifest["blocking_errors"]: + lines.append(f"- {error}") + else: + lines.append("- None") + lines.extend(["", "## Machine Readable Manifest", "", f"- `{MANIFEST_JSON}`"]) + (output_dir / CONTEXT_MD).write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def write_outputs(manifest: dict[str, Any], output_dir: pathlib.Path) -> None: + output_dir.mkdir(parents=True, exist_ok=True) + write_manifest(manifest, output_dir) + write_context(manifest, output_dir) + + +def run( + project_root: pathlib.Path, + review_id: str, + output_dir: pathlib.Path, + source_roots: list[str], + include_patterns: list[str], + exclude_patterns: list[str], + metadata_path: pathlib.Path | None, + context_profile: str, + max_file_bytes: int, +) -> int: + project_root = project_root.resolve() + output_dir = output_dir.resolve() + output_dir.mkdir(parents=True, exist_ok=True) + metadata = load_metadata(metadata_path) + if not project_root.is_dir(): + manifest = empty_manifest(project_root, review_id, context_profile, source_roots, metadata) + manifest["blocking_errors"].append(f"project_root is not a directory: {project_root}") + finalize_manifest(manifest) + write_outputs(manifest, output_dir) + return 2 + + normalized_sources = source_roots or ["."] + normalized_includes = include_patterns or ["**/*"] + manifest = build_manifest( + project_root=project_root, + review_id=review_id, + context_profile=context_profile, + source_roots_raw=normalized_sources, + include_patterns=normalized_includes, + exclude_patterns=exclude_patterns, + metadata=metadata, + max_file_bytes=max_file_bytes, + ) + write_outputs(manifest, output_dir) + return 1 if manifest["blocking_errors"] else 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Build a file-first review context index.") + parser.add_argument("--project-root", required=True, type=pathlib.Path) + parser.add_argument("--review-id", required=True) + parser.add_argument("--output-dir", required=True, type=pathlib.Path) + parser.add_argument("--source-root", action="append", default=[]) + parser.add_argument("--include-pattern", action="append", default=[]) + parser.add_argument("--exclude-pattern", action="append", default=[]) + parser.add_argument("--metadata", type=pathlib.Path) + parser.add_argument("--context-profile", default="generic") + parser.add_argument("--max-file-bytes", type=int, default=DEFAULT_MAX_FILE_BYTES) + args = parser.parse_args(argv) + return run( + project_root=args.project_root, + review_id=args.review_id, + output_dir=args.output_dir, + source_roots=args.source_root, + include_patterns=args.include_pattern, + exclude_patterns=args.exclude_pattern, + metadata_path=args.metadata, + context_profile=args.context_profile, + max_file_bytes=args.max_file_bytes, + ) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/review-context-builder/tests/test_review_context_builder.py b/skills/review-context-builder/tests/test_review_context_builder.py new file mode 100644 index 0000000..e50c0e1 --- /dev/null +++ b/skills/review-context-builder/tests/test_review_context_builder.py @@ -0,0 +1,234 @@ +from __future__ import annotations + +import json +import pathlib +import shutil +import subprocess +import sys +import tempfile +import unittest + + +SKILL_ROOT = pathlib.Path(__file__).resolve().parents[1] +REPO_ROOT = pathlib.Path(__file__).resolve().parents[3] +SCRIPT = SKILL_ROOT / "scripts" / "review_context_builder.py" +TMP_ROOT = REPO_ROOT / "tmp" / "review-context-builder-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 load_manifest(output_dir: pathlib.Path) -> dict[str, object]: + return json.loads((output_dir / "review-file-manifest.json").read_text(encoding="utf-8")) + + +class ReviewContextBuilderTest(unittest.TestCase): + def setUp(self) -> None: + TMP_ROOT.mkdir(parents=True, exist_ok=True) + self.tmp_dir = tempfile.TemporaryDirectory(dir=TMP_ROOT) + self.project_root = pathlib.Path(self.tmp_dir.name) / "project" + self.output_dir = pathlib.Path(self.tmp_dir.name) / "outputs" + self.project_root.mkdir() + + def tearDown(self) -> None: + self.tmp_dir.cleanup() + + @classmethod + def tearDownClass(cls) -> None: + if TMP_ROOT.exists(): + shutil.rmtree(TMP_ROOT) + + def run_builder(self, *extra_args: str) -> tuple[subprocess.CompletedProcess[str], dict[str, object], str]: + result = run_cli( + "--project-root", + str(self.project_root), + "--review-id", + "round-05-local-pass", + "--output-dir", + str(self.output_dir), + *extra_args, + ) + manifest = load_manifest(self.output_dir) + context = (self.output_dir / "review-context.md").read_text(encoding="utf-8") + return result, manifest, context + + def test_complete_context_build_indexes_configured_sources(self) -> None: + write_text(self.project_root / "README.md", "# Project\n") + write_text(self.project_root / "reports" / "gate-run-report.md", "# Gate report\n") + write_text(self.project_root / "decisions" / "owner-decision.md", "# Decision\n") + metadata = write_json( + pathlib.Path(self.tmp_dir.name) / "metadata.json", + { + "review_goal": "check readiness", + "non_goals": ["do not decide approval"], + "open_questions": ["is evidence complete?"], + "target_reviewer": "local reviewer", + }, + ) + + result, manifest, context = self.run_builder( + "--source-root", + ".", + "--include-pattern", + "**/*.md", + "--metadata", + str(metadata), + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(manifest["review_id"], "round-05-local-pass") + self.assertEqual(manifest["context_profile"], "generic") + self.assertEqual( + manifest["files_included"], + [ + {"path": "README.md", "size_bytes": 10, "source_root": "."}, + {"path": "decisions/owner-decision.md", "size_bytes": 11, "source_root": "."}, + {"path": "reports/gate-run-report.md", "size_bytes": 14, "source_root": "."}, + ], + ) + self.assertEqual(manifest["metadata"]["review_goal"], "check readiness") + self.assertEqual(manifest["known_non_goals"], ["do not decide approval"]) + self.assertEqual(manifest["open_questions_for_reviewer_or_agent"], ["is evidence complete?"]) + self.assertIn("reports/gate-run-report.md", manifest["important_reports"]) + self.assertIn("decisions/owner-decision.md", manifest["prior_decision_references"]) + self.assertIn("This context is an index. It does not replace reading source files.", context) + + def test_missing_source_root_is_blocking_but_outputs_manifest(self) -> None: + result, manifest, context = self.run_builder("--source-root", "missing") + + self.assertEqual(result.returncode, 1) + self.assertEqual(manifest["machine_readable_summary"]["status"], "FAIL") + self.assertEqual(manifest["missing_source_roots"], ["missing"]) + self.assertIn("Missing source root", manifest["blocking_errors"][0]) + self.assertIn("missing", context) + + def test_large_files_are_excluded_with_reason(self) -> None: + write_text(self.project_root / "small.md", "small") + write_text(self.project_root / "large.md", "0123456789") + + result, manifest, _ = self.run_builder( + "--source-root", + ".", + "--include-pattern", + "**/*.md", + "--max-file-bytes", + "5", + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual([item["path"] for item in manifest["files_included"]], ["small.md"]) + self.assertEqual( + manifest["files_excluded"], + [{"path": "large.md", "reason": "size_exceeds_limit", "size_bytes": 10}], + ) + + def test_manifest_path_normalization_uses_project_relative_posix_paths(self) -> None: + nested = write_text(self.project_root / "docs" / "nested" / "report.md", "report") + + result, manifest, _ = self.run_builder("--source-root", str(nested), "--include-pattern", "**/*.md") + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(manifest["source_roots"], ["docs/nested/report.md"]) + self.assertEqual(manifest["files_included"][0]["path"], "docs/nested/report.md") + self.assertNotIn("\\", manifest["files_included"][0]["path"]) + + def test_utf8_paths_and_chinese_filenames_are_preserved(self) -> None: + write_text(self.project_root / "材料" / "审查报告.md", "# 报告\n") + + result, manifest, context = self.run_builder( + "--source-root", + "材料", + "--include-pattern", + "**/*.md", + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(manifest["files_included"][0]["path"], "材料/审查报告.md") + self.assertIn("材料/审查报告.md", context) + + def test_stable_output_ordering_across_repeated_runs(self) -> None: + write_text(self.project_root / "zeta.md", "z") + write_text(self.project_root / "alpha.md", "a") + write_text(self.project_root / "nested" / "beta.md", "b") + + first_result, first_manifest, first_context = self.run_builder( + "--source-root", + ".", + "--include-pattern", + "**/*.md", + ) + second_result, second_manifest, second_context = self.run_builder( + "--source-root", + ".", + "--include-pattern", + "**/*.md", + ) + + self.assertEqual(first_result.returncode, 0, first_result.stderr) + self.assertEqual(second_result.returncode, 0, second_result.stderr) + self.assertEqual(first_manifest, second_manifest) + self.assertEqual(first_context, second_context) + self.assertEqual([item["path"] for item in first_manifest["files_included"]], ["alpha.md", "nested/beta.md", "zeta.md"]) + + def test_metadata_passthrough_does_not_add_ccra_fields(self) -> None: + metadata = write_json( + pathlib.Path(self.tmp_dir.name) / "metadata.json", + {"custom_key": "custom value", "round_id": "R05"}, + ) + write_text(self.project_root / "note.md", "note") + + result, manifest, _ = self.run_builder( + "--source-root", + ".", + "--include-pattern", + "**/*.md", + "--metadata", + str(metadata), + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(manifest["metadata"], {"custom_key": "custom value", "round_id": "R05"}) + self.assertNotIn("review_round", manifest) + self.assertNotIn("local_pass", manifest) + + def test_exclude_patterns_remove_matching_files(self) -> None: + write_text(self.project_root / "include.md", "include") + write_text(self.project_root / "scratch" / "exclude.md", "exclude") + + result, manifest, _ = self.run_builder( + "--source-root", + ".", + "--include-pattern", + "**/*.md", + "--exclude-pattern", + "scratch/**", + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual([item["path"] for item in manifest["files_included"]], ["include.md"]) + self.assertEqual( + manifest["files_excluded"], + [{"path": "scratch/exclude.md", "reason": "exclude_pattern"}], + ) + + +if __name__ == "__main__": + unittest.main()