feat: add review context builder skill
This commit is contained in:
parent
fdda95aea9
commit
c2a46b60ac
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
```
|
||||||
|
|
@ -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
|
||||||
|
```
|
||||||
|
|
@ -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."
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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()
|
||||||
Loading…
Reference in New Issue