from __future__ import annotations
|
|
import argparse
|
import csv
|
import hashlib
|
import json
|
from dataclasses import dataclass
|
from datetime import datetime, timezone
|
from pathlib import Path
|
from typing import Any
|
|
|
@dataclass
|
class FileCheck:
|
path: str
|
status: str
|
error_code: str
|
expected_sha256: str
|
actual_sha256: str
|
|
|
@dataclass
|
class CaseResult:
|
case_id: str
|
package_id: str
|
expected_status: str
|
status: str
|
file_count: int
|
checked_file_count: int
|
pass_file_count: int
|
fail_file_count: int
|
error_codes: list[str]
|
|
|
def sha256_file(path: Path) -> str:
|
digest = hashlib.sha256()
|
with path.open("rb") as handle:
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
digest.update(chunk)
|
return digest.hexdigest()
|
|
|
def load_manifest(path: Path) -> dict[str, Any]:
|
with path.open("r", encoding="utf-8") as handle:
|
payload = json.load(handle)
|
if not isinstance(payload, dict):
|
raise ValueError("MANIFEST_NOT_OBJECT")
|
files = payload.get("files")
|
if not isinstance(files, list):
|
raise ValueError("MANIFEST_FILES_NOT_LIST")
|
return payload
|
|
|
def _safe_resolve_case_file(case_dir: Path, relative_path: str) -> tuple[Path | None, str]:
|
raw_path = Path(relative_path)
|
if raw_path.is_absolute():
|
return None, "ABSOLUTE_PATH_NOT_ALLOWED"
|
resolved_case_dir = case_dir.resolve()
|
resolved_file = (case_dir / raw_path).resolve()
|
try:
|
resolved_file.relative_to(resolved_case_dir)
|
except ValueError:
|
return None, "PATH_OUTSIDE_CASE"
|
return resolved_file, ""
|
|
|
def _expected_status(case_id: str) -> str:
|
return "FAIL" if case_id.startswith("invalid_") else "PASS"
|
|
|
def validate_case(case_dir: Path) -> tuple[CaseResult, list[FileCheck]]:
|
case_id = case_dir.name
|
expected_status = _expected_status(case_id)
|
manifest_path = case_dir / "manifest.json"
|
file_checks: list[FileCheck] = []
|
error_codes: list[str] = []
|
|
if not manifest_path.exists():
|
return (
|
CaseResult(
|
case_id=case_id,
|
package_id="",
|
expected_status=expected_status,
|
status="FAIL",
|
file_count=0,
|
checked_file_count=0,
|
pass_file_count=0,
|
fail_file_count=0,
|
error_codes=["MANIFEST_MISSING"],
|
),
|
file_checks,
|
)
|
|
try:
|
manifest = load_manifest(manifest_path)
|
except (json.JSONDecodeError, ValueError) as exc:
|
return (
|
CaseResult(
|
case_id=case_id,
|
package_id="",
|
expected_status=expected_status,
|
status="FAIL",
|
file_count=0,
|
checked_file_count=0,
|
pass_file_count=0,
|
fail_file_count=0,
|
error_codes=[str(exc) or exc.__class__.__name__],
|
),
|
file_checks,
|
)
|
|
package_id = str(manifest.get("package_id") or "")
|
files = manifest["files"]
|
|
for item in files:
|
if not isinstance(item, dict):
|
error_codes.append("FILE_ITEM_NOT_OBJECT")
|
continue
|
rel_path = str(item.get("path") or "")
|
expected_sha = str(item.get("sha256") or "").lower()
|
target_path, path_error = _safe_resolve_case_file(case_dir, rel_path)
|
if path_error:
|
error_codes.append(path_error)
|
file_checks.append(FileCheck(rel_path, "FAIL", path_error, expected_sha, ""))
|
continue
|
if target_path is None or not target_path.exists():
|
error_codes.append("FILE_MISSING")
|
file_checks.append(FileCheck(rel_path, "FAIL", "FILE_MISSING", expected_sha, ""))
|
continue
|
actual_sha = sha256_file(target_path)
|
if actual_sha.lower() != expected_sha:
|
error_codes.append("SHA256_MISMATCH")
|
file_checks.append(FileCheck(rel_path, "FAIL", "SHA256_MISMATCH", expected_sha, actual_sha))
|
continue
|
file_checks.append(FileCheck(rel_path, "PASS", "", expected_sha, actual_sha))
|
|
fail_file_count = sum(1 for row in file_checks if row.status == "FAIL")
|
pass_file_count = sum(1 for row in file_checks if row.status == "PASS")
|
status = "PASS" if not error_codes and len(file_checks) == len(files) else "FAIL"
|
return (
|
CaseResult(
|
case_id=case_id,
|
package_id=package_id,
|
expected_status=expected_status,
|
status=status,
|
file_count=len(files),
|
checked_file_count=len(file_checks),
|
pass_file_count=pass_file_count,
|
fail_file_count=fail_file_count,
|
error_codes=sorted(set(error_codes)),
|
),
|
file_checks,
|
)
|
|
|
def write_outputs(input_root: Path, output_dir: Path) -> dict[str, Any]:
|
output_dir.mkdir(parents=True, exist_ok=True)
|
case_dirs = sorted(path for path in input_root.iterdir() if path.is_dir())
|
case_results: list[CaseResult] = []
|
file_rows: list[dict[str, str]] = []
|
|
for case_dir in case_dirs:
|
result, checks = validate_case(case_dir)
|
case_results.append(result)
|
for check in checks:
|
file_rows.append(
|
{
|
"case_id": result.case_id,
|
"package_id": result.package_id,
|
"file_path": check.path,
|
"status": check.status,
|
"error_code": check.error_code,
|
"expected_sha256": check.expected_sha256,
|
"actual_sha256": check.actual_sha256,
|
}
|
)
|
|
summary = {
|
"experiment_id": input_root.name,
|
"run_id": output_dir.name,
|
"created_at_utc": datetime.now(timezone.utc).isoformat(),
|
"input_root": str(input_root),
|
"output_dir": str(output_dir),
|
"total_cases": len(case_results),
|
"pass_count": sum(1 for row in case_results if row.status == "PASS"),
|
"fail_count": sum(1 for row in case_results if row.status == "FAIL"),
|
"expected_matrix_match_count": sum(1 for row in case_results if row.status == row.expected_status),
|
"quality_pass": all(row.status == row.expected_status for row in case_results),
|
}
|
|
with (output_dir / "case_results.csv").open("w", newline="", encoding="utf-8-sig") as handle:
|
writer = csv.DictWriter(
|
handle,
|
fieldnames=[
|
"case_id",
|
"package_id",
|
"expected_status",
|
"status",
|
"file_count",
|
"checked_file_count",
|
"pass_file_count",
|
"fail_file_count",
|
"error_codes",
|
],
|
)
|
writer.writeheader()
|
for row in case_results:
|
writer.writerow(
|
{
|
"case_id": row.case_id,
|
"package_id": row.package_id,
|
"expected_status": row.expected_status,
|
"status": row.status,
|
"file_count": row.file_count,
|
"checked_file_count": row.checked_file_count,
|
"pass_file_count": row.pass_file_count,
|
"fail_file_count": row.fail_file_count,
|
"error_codes": ";".join(row.error_codes),
|
}
|
)
|
|
with (output_dir / "file_check_details.csv").open("w", newline="", encoding="utf-8-sig") as handle:
|
writer = csv.DictWriter(
|
handle,
|
fieldnames=[
|
"case_id",
|
"package_id",
|
"file_path",
|
"status",
|
"error_code",
|
"expected_sha256",
|
"actual_sha256",
|
],
|
)
|
writer.writeheader()
|
writer.writerows(file_rows)
|
|
with (output_dir / "summary.json").open("w", encoding="utf-8") as handle:
|
json.dump(summary, handle, ensure_ascii=False, indent=2)
|
|
readout = [
|
f"# {summary['run_id']} 结果导读",
|
"",
|
"- 创建人员: ai-codex",
|
"- 文件职责: 记录 manifest 包校验实验的可读结论,不作为核心业务主表。",
|
"- 管理规范/模板: project-d/exp-doc/实验规范.md; project-d/dev-doc/编码规范.md",
|
"- 引用文件: summary.json; case_results.csv; file_check_details.csv",
|
"- 记录方式: append-only 实验日志 + 开发日志 + 审计报告交叉引用。",
|
"",
|
"## 结论",
|
"",
|
f"- total_cases: {summary['total_cases']}",
|
f"- pass_count: {summary['pass_count']}",
|
f"- fail_count: {summary['fail_count']}",
|
f"- expected_matrix_match_count: {summary['expected_matrix_match_count']}",
|
f"- quality_pass: {summary['quality_pass']}",
|
]
|
with (output_dir / "readout.md").open("w", encoding="utf-8") as handle:
|
handle.write("\n".join(readout) + "\n")
|
|
return summary
|
|
|
def main() -> int:
|
parser = argparse.ArgumentParser(description="Validate manifest packages for EXP-D-HEAVY-001.")
|
parser.add_argument("--input-root", required=True, type=Path)
|
parser.add_argument("--output-dir", required=True, type=Path)
|
args = parser.parse_args()
|
summary = write_outputs(args.input_root, args.output_dir)
|
return 0 if summary["quality_pass"] else 1
|
|
|
if __name__ == "__main__":
|
raise SystemExit(main())
|