619 lines
31 KiB
Python
619 lines
31 KiB
Python
#!/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())
|