feat: add review context builder skill

This commit is contained in:
wantsong 2026-06-19 03:24:01 +08:00
parent fdda95aea9
commit c2a46b60ac
8 changed files with 754 additions and 0 deletions

View File

@ -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 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 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-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: 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 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 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-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. `-All` installs only registry rows with an installable status and `default` install policy. Optional Skills must be named explicitly.

View File

@ -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 | | `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 | | `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-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 | | `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 ## Status Values

View File

@ -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
```

View File

@ -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
```

View File

@ -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."

View File

@ -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"
}

View File

@ -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())

View File

@ -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()