Files
ai-contract-platform/yuqei-infra/scripts/build-release-worktree-ownership.py
T

619 lines
31 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Classify every dirty-worktree path and audit release-graph closure."""
from __future__ import annotations
import argparse
import ast
import hashlib
import json
import re
import subprocess
import sys
import tomllib
from collections import Counter
from datetime import datetime, timedelta, timezone
from pathlib import Path, PurePosixPath
from typing import Any
SCHEMA_VERSION = "yuqei.release-worktree-ownership.v1"
CATEGORIES = (
"release_source_include",
"release_test_include",
"release_evidence_compact_include",
"generated_evidence_retain_exclude",
"local_binary_exclude",
"secret_or_runtime_block",
"needs_manual_review",
)
CLOSEOUT_PREFIX = "docs/qa/multiscene-closeout-20260910/"
MAIN_PLAN = "project-docs/AI合同平台-多场景完整交付与开发收口方案-2026-09-10.md"
CURRENT_RELEASE_CONTEXT_DOCS = {
"project-docs/上线收口-执行约束-2026-09-10.md",
"project-docs/RC06-single-reviewer-one-click-signing-runbook-2026-09-27.md",
}
HISTORICAL_PROJECT_DOCS = {
"project-docs/V4-website-shell-redesign-2026-08-28.md",
}
EXTERNAL_EVIDENCE_TEST_HELPERS = {
"yuqei-infra/scripts/build-tp05-18-regression-evidence.py",
"yuqei-infra/scripts/tp06-22/scene-quality-checks.py",
"yuqei-infra/scripts/tp06-23-1/replay-candidate.py",
"yuqei-infra/scripts/tp06-23-1/run-nonempty-drafting.py",
}
COMPACT_EVIDENCE_NAMES = {
"delivery-baseline-v1.json",
"remaining-closure-register-20260919.json",
"rc03-current-candidate-evidence-index-v3.json",
"rc03-current-candidate-evidence-index-v3.md",
"rc04-immutable-manifest-tool-v3.md",
"rc04-release-manifest-tools-v3.xml",
"rc04-immutable-manifest-tool-v6.md",
"rc04-release-manifest-tools-v6.xml",
}
COMPACT_SUBPATHS: set[str] = set()
RUNTIME_COMPONENTS = {
"python_sdk": ("yuqei-ai-sdk-python/src/", "yuqei-ai-sdk-python/pyproject.toml"),
"ai_api_worker": (
"yuqei-ai-platform/services/ai-platform-api/src/",
"yuqei-ai-platform/services/ai-platform-api/pyproject.toml",
"yuqei-ai-platform/services/worker/src/",
"yuqei-ai-platform/services/worker/pyproject.toml",
),
"contract_api": (
"yuqei-contract-platform/services/contract-api/src/",
"yuqei-contract-platform/services/contract-api/pyproject.toml",
),
"web_bff_design_system": (
"yuqei-contract-platform/apps/contract-web/src/",
"yuqei-contract-platform/apps/contract-web/package.json",
"yuqei-contract-platform/packages/design-system/src/",
"package.json",
"package-lock.json",
),
"openapi": (
"yuqei-contract-platform/packages/contract-openapi/",
"yuqei-ai-platform/packages/ai-openapi/",
),
"migrations": (
"yuqei-contract-platform/services/contract-api/migrations/",
"yuqei-ai-platform/services/ai-platform-api/migrations/",
),
"deployment": (
".github/workflows/build-test-quality-gate.yml",
".github/workflows/security-quality-gate.yml",
"yuqei-infra/docker/",
"yuqei-infra/compose/",
"yuqei-infra/nginx/",
"yuqei-infra/requirements/",
"yuqei-infra/deploy/",
"yuqei-infra/release/",
"yuqei-infra/release-trust/third-party-materials-baseline.json",
"yuqei-infra/scripts/linux_release_common.py",
"yuqei-infra/scripts/run-linux-release-build.py",
"yuqei-infra/scripts/build-installable-release.py",
"yuqei-infra/scripts/build-infrastructure-image-bundle.py",
"yuqei-infra/scripts/build-third-party-materials.py",
"yuqei-infra/scripts/build-rc06-publication-batch.py",
"yuqei-infra/scripts/rc06-single-reviewer-signing.py",
"yuqei-infra/scripts/Launch-ReleaseSigner.ps1",
"yuqei-infra/scripts/Initialize-ReleaseSigner.cmd",
"yuqei-infra/scripts/Approve-ReleaseCandidate.cmd",
"yuqei-infra/scripts/build-release-candidate-manifest.py",
"yuqei-infra/scripts/build-release-worktree-ownership.py",
"yuqei-infra/tests/test_installable_release_assembler_hardening.py",
"yuqei-infra/tests/test_infrastructure_image_bundle.py",
"yuqei-infra/tests/test_first_release_snapshot_restore_planner.py",
"yuqei-infra/tests/test_rollback_rehearsal_planner.py",
"yuqei-infra/tests/test_third_party_materials.py",
"yuqei-infra/tests/test_release_package_executor.py",
"yuqei-infra/tests/test_release_candidate_manifest_tools.py",
"yuqei-infra/tests/test_rc06_publication_batch.py",
"yuqei-infra/tests/test_production_compose_preflight.py",
"yuqei-infra/tests/test_production_compose_merge_gate.py",
"yuqei-infra/tests/test_linux_release_build_tools.py",
),
}
REQUIRED_WORKTREE_ANCHORS = (
".github/workflows/build-test-quality-gate.yml",
".github/workflows/security-quality-gate.yml",
"package.json",
"package-lock.json",
"yuqei-ai-sdk-python/pyproject.toml",
"yuqei-ai-platform/services/ai-platform-api/pyproject.toml",
"yuqei-ai-platform/services/worker/pyproject.toml",
"yuqei-contract-platform/services/contract-api/pyproject.toml",
"yuqei-contract-platform/apps/contract-web/package.json",
"yuqei-contract-platform/packages/design-system/package.json",
"yuqei-contract-platform/packages/contract-openapi/openapi.yaml",
"yuqei-ai-platform/packages/ai-openapi/openapi.yaml",
"yuqei-contract-platform/services/contract-api/migrations/versions/20260909_0079_add_party_profiles.py",
"yuqei-ai-platform/services/ai-platform-api/migrations/versions/20260826_0028_add_stratified_quality_reviews.py",
"yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/drafting_prompt_modules.json",
"yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/review_scene_modules.json",
"yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/template_assets/v1_contract_templates/source-catalog.json",
"yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/template_assets/v1_contract_templates/scene-packs/batch-a.json",
"yuqei-contract-platform/services/contract-api/src/yuqei_contract_api/template_assets/v1_contract_templates/scene-packs/batch-b.json",
"yuqei-infra/docker/contract-api.Dockerfile",
"yuqei-infra/docker/ai-platform-api.Dockerfile",
"yuqei-infra/docker/worker.Dockerfile",
"yuqei-infra/docker/contract-web.Dockerfile",
"yuqei-infra/compose/docker-compose.yml",
"yuqei-infra/compose/docker-compose.production.yml",
"yuqei-infra/compose/docker-compose.production.offline.yml",
"yuqei-infra/nginx/templates/default.conf.template",
"yuqei-infra/scripts/build-release-candidate-manifest.py",
"yuqei-infra/scripts/build-release-worktree-ownership.py",
"yuqei-infra/deploy/preflight-production.py",
"yuqei-infra/deploy/verify-production-compose.py",
"yuqei-infra/deploy/release-package.py",
"yuqei-infra/deploy/plan-first-release-snapshot-restore.py",
"yuqei-infra/deploy/plan-rollback-rehearsal.py",
"yuqei-infra/release-trust/third-party-materials-baseline.json",
"yuqei-infra/release/linux-release-security-policy.v1.json",
"yuqei-infra/scripts/linux_release_common.py",
"yuqei-infra/scripts/run-linux-release-build.py",
"yuqei-infra/scripts/build-installable-release.py",
"yuqei-infra/scripts/build-infrastructure-image-bundle.py",
"yuqei-infra/scripts/build-third-party-materials.py",
"yuqei-infra/scripts/build-rc06-publication-batch.py",
"yuqei-infra/scripts/rc06-single-reviewer-signing.py",
"yuqei-infra/scripts/Launch-ReleaseSigner.ps1",
"yuqei-infra/scripts/Initialize-ReleaseSigner.cmd",
"yuqei-infra/scripts/Approve-ReleaseCandidate.cmd",
"yuqei-infra/tests/test_production_compose_preflight.py",
"yuqei-infra/tests/test_production_compose_merge_gate.py",
"yuqei-infra/tests/test_release_package_executor.py",
"yuqei-infra/tests/test_linux_release_build_tools.py",
"yuqei-infra/tests/test_installable_release_assembler_hardening.py",
"yuqei-infra/tests/test_infrastructure_image_bundle.py",
"yuqei-infra/tests/test_first_release_snapshot_restore_planner.py",
"yuqei-infra/tests/test_rollback_rehearsal_planner.py",
"yuqei-infra/tests/test_third_party_materials.py",
"yuqei-infra/tests/test_release_candidate_manifest_tools.py",
"yuqei-infra/tests/test_rc06_publication_batch.py",
)
def _sha256(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def _safe_template_name(name: str) -> bool:
return any(token in name.casefold() for token in (".example", ".sample", ".template", ".dist"))
def classify_path(path: str, *, size: int | None = None) -> tuple[str, str, str]:
pure = PurePosixPath(path)
lowered = path.casefold()
name = pure.name.casefold()
parts = [part.casefold() for part in pure.parts]
suffix = pure.suffix.casefold()
if (name == ".env" or name.startswith(".env.")) and not _safe_template_name(name):
return "secret_or_runtime_block", "SEC-ENV", "non-template environment file must never enter a release commit"
if suffix in {".pem", ".p12", ".pfx", ".jks", ".kdbx"} or name in {
"id_rsa", "id_ed25519", ".npmrc", ".pypirc", "credentials.json", "service-account.json"
}:
return "secret_or_runtime_block", "SEC-CREDENTIAL", "credential/private-key-like path blocks source freeze"
if re.search(r"(?:^|/)(?:secrets?|credentials?)(?:[._-]|/)", lowered) and not _safe_template_name(name):
return "secret_or_runtime_block", "SEC-NAMED", "secret-like path requires removal from candidate and manual handling"
if path.startswith("docs/qa/") and suffix in {".sqlite", ".sqlite3", ".db", ".log", ".pid"}:
return (
"generated_evidence_retain_exclude",
"EVIDENCE-RUNTIME-ARTIFACT",
"raw QA database/log/process artifact is retained as generated evidence and excluded from release source",
)
if suffix in {".sqlite", ".sqlite3", ".db", ".log", ".pid"} or ".runtime" in parts:
return "secret_or_runtime_block", "RUNTIME-DATA", "raw runtime data/log/database must be retained locally and blocked"
if any(part in {"node_modules", "__pycache__", ".pytest_cache", ".mypy_cache", ".next", "dist-test"} for part in parts):
return "local_binary_exclude", "LOCAL-BUILD", "local cache/build/install output is not release source"
if suffix in {".whl", ".zip"}:
return "local_binary_exclude", "LOCAL-ARCHIVE", "local wheel/archive is not an immutable candidate image or source"
if (
path in EXTERNAL_EVIDENCE_TEST_HELPERS
or path.startswith("yuqei-infra/fixtures/")
or path.startswith("test_support/")
or "/tests/" in f"/{path}"
or pure.name.startswith("test_")
or ".test." in pure.name
) and not path.startswith(("docs/qa/", "project-docs/")):
return "release_test_include", "TEST", "test or deterministic fixture required to certify included source"
if path == MAIN_PLAN or path in CURRENT_RELEASE_CONTEXT_DOCS or path in {
"project-docs/AI合同平台-模板与Prompt专项审查及优化扩充方案-2026-08-28.md",
"project-docs/template-prompt-expansion-backlog-20260828.json",
}:
return "release_evidence_compact_include", "EVIDENCE-PLAN", "current scope/baseline document is compact release context"
if path in HISTORICAL_PROJECT_DOCS:
return (
"generated_evidence_retain_exclude",
"EVIDENCE-HISTORICAL-DESIGN",
"historical design record is retained for traceability but is not current-candidate release context",
)
if path.startswith("project-docs/qa-artifacts/") or re.search(r"(?:/|-)TP(?:0[1-6]|\d)", path, re.IGNORECASE):
return "generated_evidence_retain_exclude", "EVIDENCE-HISTORY", "historical phase output is retained but not current-candidate evidence"
if path.startswith("project-docs/"):
return "needs_manual_review", "MANUAL-PROJECT-DOC", "project document is not part of the reviewed current-plan allowlist"
if path in COMPACT_SUBPATHS:
return "release_evidence_compact_include", "EVIDENCE-FINAL-SUMMARY", "explicit current-gate compact summary"
if path.startswith(CLOSEOUT_PREFIX):
relative = path[len(CLOSEOUT_PREFIX):]
if "/" not in relative and pure.name in COMPACT_EVIDENCE_NAMES:
return "release_evidence_compact_include", "EVIDENCE-COMPACT", "explicit compact current-gate evidence allowlist"
return "generated_evidence_retain_exclude", "EVIDENCE-GENERATED", "raw, superseded, partial, or high-cardinality QA output is retained outside release source"
if path.startswith("docs/qa/"):
return "generated_evidence_retain_exclude", "EVIDENCE-OTHER", "non-current QA output is retained but excluded from candidate source"
if path.startswith(("yuqei-ai-platform/", "yuqei-ai-sdk-python/", "yuqei-contract-platform/", "yuqei-infra/", ".github/")):
return "release_source_include", "SOURCE-COMPONENT", "application, build, migration, asset, API, or deployment source"
if path in {".dockerignore", "package.json", "package-lock.json", "tsconfig.base.json", "pyproject.toml", "start-dev.ps1"}:
return "release_source_include", "SOURCE-ROOT", "root build/dependency/development entrypoint"
return "needs_manual_review", "MANUAL-UNKNOWN", "path is outside reviewed component and evidence rules"
def parse_status(repo: Path) -> tuple[bytes, list[dict[str, Any]]]:
completed = subprocess.run(
["git", "-C", str(repo), "status", "--porcelain=v1", "-z", "-uall"],
check=True,
capture_output=True,
)
raw = completed.stdout
records = raw.split(b"\0")
entries: list[dict[str, Any]] = []
index = 0
while index < len(records) - 1:
decoded = records[index].decode("utf-8", "strict")
index += 1
if not decoded:
continue
status = decoded[:2]
path = decoded[3:]
original_path = None
if status[0] in {"R", "C"} or status[1] in {"R", "C"}:
original_path = records[index].decode("utf-8", "strict")
index += 1
absolute = repo / Path(*PurePosixPath(path).parts)
size = absolute.stat().st_size if absolute.is_file() else None
category, rule_id, reason = classify_path(path, size=size)
entries.append(
{
"path": path,
"original_path": original_path,
"status": status,
"index_status": status[0],
"worktree_status": status[1],
"exists": absolute.exists(),
"size": size,
"category": category,
"rule_id": rule_id,
"reason": reason,
}
)
entries.sort(key=lambda item: item["path"].encode("utf-8"))
return raw, entries
def _path_matches(path: str, prefixes: tuple[str, ...]) -> bool:
return any(path == prefix or path.startswith(prefix) for prefix in prefixes)
def _file_hash(repo: Path, path: str) -> str:
return _sha256((repo / Path(*PurePosixPath(path).parts)).read_bytes())
def _migration_heads(repo: Path, prefix: str) -> dict[str, Any]:
root = repo / Path(*PurePosixPath(prefix).parts)
migrations = []
for path in sorted(root.glob("*.py")):
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
values: dict[str, Any] = {}
for node in tree.body:
if isinstance(node, ast.Assign):
for target in node.targets:
if isinstance(target, ast.Name) and target.id in {"revision", "down_revision"}:
values[target.id] = ast.literal_eval(node.value)
elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name) and node.target.id in {"revision", "down_revision"}:
values[node.target.id] = ast.literal_eval(node.value)
if not isinstance(values.get("revision"), str):
continue
down = values.get("down_revision")
parents = [] if down is None else [down] if isinstance(down, str) else list(down)
migrations.append(
{
"path": path.relative_to(repo).as_posix(),
"revision": values["revision"],
"down_revisions": parents,
"sha256": _sha256(path.read_bytes()),
}
)
revisions = {item["revision"] for item in migrations}
referenced = {parent for item in migrations for parent in item["down_revisions"] if parent in revisions}
return {"root": prefix, "count": len(migrations), "heads": sorted(revisions - referenced), "migrations": migrations}
def _prompt_module_records(payload: Any, path: str) -> list[dict[str, Any]]:
records = payload.get("modules") if isinstance(payload, dict) else payload
if not isinstance(records, list) or not all(isinstance(item, dict) for item in records):
raise ValueError(f"Prompt definition must be a list or an object with a modules list: {path}")
return records
def build_inventory(repo: Path) -> dict[str, Any]:
baseline_path = repo / Path(*PurePosixPath(CLOSEOUT_PREFIX + "delivery-baseline-v1.json").parts)
baseline = json.loads(baseline_path.read_text(encoding="utf-8"))
assets = [item["id"] for item in baseline["assets"]]
prompt_paths = (
"yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/drafting_prompt_modules.json",
"yuqei-ai-platform/services/ai-platform-api/src/yuqei_ai_platform_api/review_scene_modules.json",
)
prompt_files = []
prompt_keys = []
for path in prompt_paths:
modules = _prompt_module_records(
json.loads((repo / Path(*PurePosixPath(path).parts)).read_text(encoding="utf-8")),
path,
)
keys = [item["key"] for item in modules]
prompt_keys.extend(keys)
prompt_files.append({"path": path, "sha256": _file_hash(repo, path), "keys": keys})
return {
"assets": {
"basis": CLOSEOUT_PREFIX + "delivery-baseline-v1.json",
"count": len(assets),
"unique_count": len(set(assets)),
"ids": assets,
},
"prompts": {
"count": len(prompt_keys),
"unique_count": len(set(prompt_keys)),
"keys": prompt_keys,
"definition_files": prompt_files,
},
}
def closure_audit(repo: Path, entries: list[dict[str, Any]]) -> dict[str, Any]:
components = []
all_gaps = []
include_categories = {"release_source_include", "release_test_include"}
for component, prefixes in RUNTIME_COMPONENTS.items():
changed = [item for item in entries if _path_matches(item["path"], prefixes)]
gaps = [item["path"] for item in changed if item["category"] not in include_categories]
all_gaps.extend({"component": component, "path": path} for path in gaps)
components.append(
{
"component": component,
"prefixes": list(prefixes),
"changed_path_count": len(changed),
"included_source_count": sum(item["category"] == "release_source_include" for item in changed),
"included_test_count": sum(item["category"] == "release_test_include" for item in changed),
"include_gaps": gaps,
}
)
missing_anchors = [path for path in REQUIRED_WORKTREE_ANCHORS if not (repo / Path(*PurePosixPath(path).parts)).is_file()]
inventory = build_inventory(repo)
dependency_paths = [
"package.json",
"package-lock.json",
"yuqei-ai-sdk-python/pyproject.toml",
"yuqei-ai-platform/services/ai-platform-api/pyproject.toml",
"yuqei-ai-platform/services/worker/pyproject.toml",
"yuqei-contract-platform/services/contract-api/pyproject.toml",
"yuqei-infra/requirements/contract-api-linux-x86_64-py312.txt",
"yuqei-infra/requirements/ai-runtime-linux-x86_64-py312.txt",
]
dependencies = []
for path in dependency_paths:
absolute = repo / Path(*PurePosixPath(path).parts)
item: dict[str, Any] = {"path": path, "exists": absolute.is_file()}
if absolute.is_file():
item.update({"sha256": _sha256(absolute.read_bytes()), "size": absolute.stat().st_size})
if path.endswith("pyproject.toml"):
item["declared_dependencies"] = tomllib.loads(absolute.read_text(encoding="utf-8"))["project"].get("dependencies", [])
dependencies.append(item)
openapi = []
for path in (
"yuqei-contract-platform/packages/contract-openapi/openapi.yaml",
"yuqei-ai-platform/packages/ai-openapi/openapi.yaml",
):
absolute = repo / Path(*PurePosixPath(path).parts)
openapi.append({"path": path, "sha256": _sha256(absolute.read_bytes()), "size": absolute.stat().st_size})
config_paths = [
path
for path in REQUIRED_WORKTREE_ANCHORS
if path.startswith(
(
"yuqei-infra/docker/",
"yuqei-infra/compose/",
"yuqei-infra/nginx/",
"yuqei-infra/deploy/",
"yuqei-infra/release/",
"yuqei-infra/release-trust/",
)
)
or path in {
"yuqei-infra/scripts/linux_release_common.py",
"yuqei-infra/scripts/run-linux-release-build.py",
"yuqei-infra/scripts/build-installable-release.py",
"yuqei-infra/scripts/build-infrastructure-image-bundle.py",
"yuqei-infra/scripts/build-third-party-materials.py",
}
]
ownership_blockers = [
item["path"]
for item in entries
if item["category"] in {"secret_or_runtime_block", "needs_manual_review"}
]
if all_gaps or missing_anchors:
closure_status = "draft_runtime_graph_has_gaps"
elif ownership_blockers:
closure_status = "draft_runtime_graph_closed_ownership_blocks_remain"
else:
closure_status = "draft_path_rules_closed"
return {
"status_path_component_closure": components,
"include_gaps": all_gaps,
"missing_required_anchors": missing_anchors,
"assets_and_prompts": inventory,
"dependencies": dependencies,
"dependency_security": {
"contract_lock": "pip_audit_2.10.1_osv_zero_known_vulnerabilities_at_audit_time",
"contract_security_floor": "pillow>=12.3.0 (transitive resolver input; not misrepresented as a direct project dependency)",
"contract_target": "CPython 3.12 / Linux x86_64 / glibc >= 2.28",
"wheel_platform_preference": ["manylinux_2_28_x86_64", "manylinux2014_x86_64"],
"container_guard": "contract-api.Dockerfile asserts glibc >= 2.28 before installing the hash lock",
"ai_lock": "pip_audit_2.10.1_osv_zero_known_vulnerabilities_at_audit_time",
},
"openapi": openapi,
"migrations": [
_migration_heads(repo, "yuqei-contract-platform/services/contract-api/migrations/versions"),
_migration_heads(repo, "yuqei-ai-platform/services/ai-platform-api/migrations/versions"),
],
"configuration": [
{"path": path, "sha256": _file_hash(repo, path)}
for path in config_paths
if (repo / Path(*PurePosixPath(path).parts)).is_file()
],
"ownership_blockers": ownership_blockers,
"closure_status": closure_status,
}
def build_record(repo: Path) -> dict[str, Any]:
raw, entries = parse_status(repo)
counts = Counter(item["category"] for item in entries)
closure = closure_audit(repo, entries)
return {
"schema_version": SCHEMA_VERSION,
"captured_at": datetime.now(timezone(timedelta(hours=8))).isoformat(timespec="seconds"),
"status": "draft_after_rc03_current_candidate_before_reviewed_commit",
"release_claim": False,
"git": {
"head": subprocess.check_output(["git", "-C", str(repo), "rev-parse", "HEAD"], text=True).strip(),
"branch": subprocess.check_output(["git", "-C", str(repo), "branch", "--show-current"], text=True).strip(),
"porcelain_command": "git status --porcelain=v1 -z -uall",
"porcelain_sha256": _sha256(raw),
"path_count": len(entries),
},
"classification_rules": [
{"category": category, "definition": definition}
for category, definition in (
("release_source_include", "application/build/config/migration/asset/API source required by the candidate"),
("release_test_include", "tests and deterministic fixtures that certify included source"),
("release_evidence_compact_include", "explicit current-gate compact evidence and scope records"),
("generated_evidence_retain_exclude", "raw, historical, partial, superseded, or high-cardinality evidence retained locally"),
("local_binary_exclude", "local caches, build products, wheels, and archives retained without release inclusion"),
("secret_or_runtime_block", "secret-like path or runtime data that blocks source freeze"),
("needs_manual_review", "ownership or release relevance cannot be inferred safely"),
)
],
"category_counts": {category: counts.get(category, 0) for category in CATEGORIES},
"paths": entries,
"closure": closure,
"required_actions": [
"Rebuild this draft if any source, test, evidence, or worktree status changes before the reviewed commit is created.",
"Resolve every secret_or_runtime_block and needs_manual_review path without deleting or moving user files automatically.",
"Do not add raw SQLite, logs, caches, dist-test wheels, archives, resolved secrets, or high-cardinality QA trees to the release commit.",
"Retain the glibc >= 2.28 build guard and re-run the hash download plus pip-audit if the Contract lock changes.",
"After a clean reviewed commit exists, run build-release-candidate-manifest.py with its full commit oid and an output outside the repository.",
],
}
def render_markdown(record: dict[str, Any]) -> str:
counts = record["category_counts"]
closure = record["closure"]
blocks = [item for item in record["paths"] if item["category"] == "secret_or_runtime_block"]
manuals = [item for item in record["paths"] if item["category"] == "needs_manual_review"]
lines = [
"# RC-04 工作树归属草案(v1)",
"",
"> 状态:**仅为草案**。本快照已在 RC-03 当前候选门禁完成后重建;若提交前源码、测试、证据或工作树状态再变化,必须再次生成。本文件不是 add/commit/tag/push 授权,也不是发布候选。",
"",
f"- 快照时间:`{record['captured_at']}`",
f"- Git HEAD:`{record['git']['head']}`",
f"- 分支:`{record['git']['branch'] or '(detached)'}`",
f"- `git status --porcelain=v1 -z -uall` 路径:**{record['git']['path_count']}**",
f"- 状态字节 SHA256:`{record['git']['porcelain_sha256']}`",
"",
"## 分类计数",
"",
"| 分类 | 数量 |",
"| --- | ---: |",
]
lines.extend(f"| `{category}` | {counts[category]} |" for category in CATEGORIES)
lines.extend(
[
"",
"逐路径分类、Git XY 状态、大小、规则和原因见同目录 `rc04-worktree-ownership-draft-v1.json`;JSON 的 `paths` 数量与本快照 porcelain 路径数相等。所有排除项只是不进入候选,均保留在当前工作树,未删除或移动。",
"",
"## 阻断项",
"",
]
)
if blocks:
lines.extend(f"- `{item['path']}` — {item['reason']}" for item in blocks)
else:
lines.append("- 当前快照没有 secret/runtime 路径阻断;最终冻结前仍须重新扫描。")
lines.extend(["", "## 需要人工确认的路径", ""])
if manuals:
lines.extend(f"- `{item['path']}` — {item['reason']}" for item in manuals)
else:
lines.append("- 无。")
lines.extend(
[
"",
"## 运行与构建闭包",
"",
"| 组件 | 变更路径 | source include | test include | include 漏项 |",
"| --- | ---: | ---: | ---: | ---: |",
]
)
for component in closure["status_path_component_closure"]:
lines.append(
f"| `{component['component']}` | {component['changed_path_count']} | "
f"{component['included_source_count']} | {component['included_test_count']} | {len(component['include_gaps'])} |"
)
lines.extend(
[
"",
f"- 闭包状态:`{closure['closure_status']}`",
f"- 缺失必需锚点:{len(closure['missing_required_anchors'])}",
f"- 状态路径 include 漏项:{len(closure['include_gaps'])}",
f"- 仍需处理的 ownership 阻断:{len(closure['ownership_blockers'])}",
f"- 资产清单:{closure['assets_and_prompts']['assets']['count']} 个,唯一 {closure['assets_and_prompts']['assets']['unique_count']} 个。",
f"- Prompt 清单:{closure['assets_and_prompts']['prompts']['count']} 个,唯一 {closure['assets_and_prompts']['prompts']['unique_count']} 个。",
f"- Contract 迁移 head:`{', '.join(closure['migrations'][0]['heads'])}`;AI 迁移 head:`{', '.join(closure['migrations'][1]['heads'])}`。",
"- OpenAPI、依赖清单、Docker/Compose/Nginx 逐文件 SHA256 记录在 JSON `closure` 中。",
"- Contract Python lock 安全状态:固定 `pip-audit 2.10.1` + OSV 当前为 0 个已知漏洞;Pillow 12.3.0 使用 manylinux_2_28 wheel,Dockerfile 在安装前强制 glibc >= 2.28。",
"",
"## 后续动作",
"",
]
)
lines.extend(f"{index}. {action}" for index, action in enumerate(record["required_actions"], 1))
return "\n".join(lines) + "\n"
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--repo", type=Path, default=Path.cwd())
parser.add_argument("--output-json", type=Path, required=True)
parser.add_argument("--output-markdown", type=Path, required=True)
args = parser.parse_args(argv)
record = build_record(args.repo.resolve())
args.output_json.parent.mkdir(parents=True, exist_ok=True)
args.output_markdown.parent.mkdir(parents=True, exist_ok=True)
args.output_json.write_text(json.dumps(record, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", newline="\n")
args.output_markdown.write_text(render_markdown(record), encoding="utf-8", newline="\n")
print(json.dumps({"paths": record["git"]["path_count"], "counts": record["category_counts"]}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())