G0~G8 성과·동맹 측정 OS 작업 일괄 고정
8월 7일까지 워킹트리에만 남아 있던 미커밋 작업을 커밋한다. 여러 사본 폴더(worktree·clone)에 흩어져 있던 중간 스냅샷을 정리하기 전에 원본을 git 이력으로 고정하는 것이 목적이다. - contracts/routes/services: measurement, outcome_trajectory, rupture_repair, deliberate_practice, calibration_transfer, supervision_research, multimodal_alliance, continuous_improvement 계열 신규 모듈과 테스트 - infra/db/init: 07~16 마이그레이션(측정 기반~calibration transfer 실행) - apps/web: 세션 리뷰 카드·관리 화면·E2E 스펙 추가 - docs/ops: G0~G8 라이브 통합·배포·롤백 증거 문서와 evidence JSON/PNG - scripts: smoke·ledger·릴리스 에이전트·NAS 프리뷰 운영 스크립트 engine.public 로그 .bak과 apps/web/test-results 산출물은 커밋에서 제외했다.
This commit is contained in:
parent
93dd8f82d7
commit
16e791e044
390 changed files with 243188 additions and 499 deletions
724
scripts/build-outcome-os-release-patch.py
Normal file
724
scripts/build-outcome-os-release-patch.py
Normal file
|
|
@ -0,0 +1,724 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Assemble and dry-run a clean Outcome/Alliance OS release patch.
|
||||
|
||||
The source worktree is read-only except for the declared patch artifact. A
|
||||
clean HEAD tree is materialized under the system temporary directory, related
|
||||
whole files are overlaid, mixed documents are reconstructed from an exact hunk
|
||||
map, and api.gen.ts is regenerated with the repository's official npm script.
|
||||
The candidate uses canonical LF hunks, must apply to both a clean checkout and
|
||||
an isolated canonical index, and replaces the prior artifact only after every
|
||||
guard passes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import difflib
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_MAP = REPO_ROOT / "docs/ops/outcome-os-release-hunk-map-2026-08-07.json"
|
||||
HUNK_HEADER = re.compile(
|
||||
r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@"
|
||||
)
|
||||
|
||||
|
||||
class AssemblyError(RuntimeError):
|
||||
"""Release assembly failed closed."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Hunk:
|
||||
header: str
|
||||
raw: str
|
||||
old_start: int
|
||||
old_count: int
|
||||
new_start: int
|
||||
new_count: int
|
||||
removed: tuple[str, ...]
|
||||
added: tuple[str, ...]
|
||||
|
||||
@property
|
||||
def sha256(self) -> str:
|
||||
return hashlib.sha256(self.raw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--map", type=Path, default=DEFAULT_MAP)
|
||||
parser.add_argument("--json", action="store_true", help="emit JSON result")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def run(
|
||||
argv: list[str],
|
||||
*,
|
||||
cwd: Path = REPO_ROOT,
|
||||
env: dict[str, str] | None = None,
|
||||
allow_failure: bool = False,
|
||||
) -> subprocess.CompletedProcess[bytes]:
|
||||
result = subprocess.run(
|
||||
argv,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode and not allow_failure:
|
||||
stdout = result.stdout.decode("utf-8", errors="replace")[-2000:]
|
||||
stderr = result.stderr.decode("utf-8", errors="replace")[-4000:]
|
||||
raise AssemblyError(
|
||||
f"command failed ({result.returncode}): {' '.join(argv)}\n"
|
||||
f"stdout:\n{stdout}\nstderr:\n{stderr}"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def load_json(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise AssemblyError(f"cannot read JSON {path}: {exc}") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise AssemblyError(f"JSON root must be an object: {path}")
|
||||
return value
|
||||
|
||||
|
||||
def canonical_path(value: Any) -> str:
|
||||
if not isinstance(value, str) or not value or "\\" in value:
|
||||
raise AssemblyError(f"path is not canonical: {value!r}")
|
||||
candidate = PurePosixPath(value)
|
||||
if candidate.is_absolute() or ".." in candidate.parts or str(candidate) != value:
|
||||
raise AssemblyError(f"path escapes repository: {value!r}")
|
||||
return value
|
||||
|
||||
|
||||
def current_head() -> str:
|
||||
return run(["git", "rev-parse", "HEAD"]).stdout.decode("ascii").strip()
|
||||
|
||||
|
||||
def git_dirty_paths() -> set[str]:
|
||||
payload = run(
|
||||
["git", "status", "--porcelain=v1", "-z", "--untracked-files=all"]
|
||||
).stdout
|
||||
records = payload.split(b"\0")
|
||||
paths: set[str] = set()
|
||||
index = 0
|
||||
while index < len(records):
|
||||
record = records[index]
|
||||
index += 1
|
||||
if not record:
|
||||
continue
|
||||
text = record.decode("utf-8", errors="surrogateescape")
|
||||
if len(text) < 4 or text[2] != " ":
|
||||
raise AssemblyError(f"unexpected git status record: {text!r}")
|
||||
status = text[:2]
|
||||
path = text[3:].replace("\\", "/")
|
||||
paths.add(path)
|
||||
if status[0] in {"R", "C"} or status[1] in {"R", "C"}:
|
||||
if index >= len(records) or not records[index]:
|
||||
raise AssemblyError(f"rename/copy lacks source path: {text!r}")
|
||||
index += 1
|
||||
return paths
|
||||
|
||||
|
||||
def file_digest(path: Path) -> str | None:
|
||||
if not path.exists():
|
||||
return None
|
||||
if not path.is_file():
|
||||
raise AssemblyError(f"dirty path is not a regular file: {path}")
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def snapshot_worktree() -> dict[str, str | None]:
|
||||
return {
|
||||
path: file_digest(REPO_ROOT / path)
|
||||
for path in sorted(git_dirty_paths())
|
||||
}
|
||||
|
||||
|
||||
def verify_worktree_unchanged(
|
||||
before: dict[str, str | None], output_path: str
|
||||
) -> None:
|
||||
after = snapshot_worktree()
|
||||
before_without_output = {k: v for k, v in before.items() if k != output_path}
|
||||
after_without_output = {k: v for k, v in after.items() if k != output_path}
|
||||
if before_without_output != after_without_output:
|
||||
before_keys = set(before_without_output)
|
||||
after_keys = set(after_without_output)
|
||||
added = sorted(after_keys - before_keys)
|
||||
removed = sorted(before_keys - after_keys)
|
||||
changed = sorted(
|
||||
key
|
||||
for key in before_keys & after_keys
|
||||
if before_without_output[key] != after_without_output[key]
|
||||
)
|
||||
raise AssemblyError(
|
||||
"source worktree changed during assembly: "
|
||||
f"added={added}, removed={removed}, changed={changed}"
|
||||
)
|
||||
|
||||
|
||||
def parse_hunks(path: str) -> list[Hunk]:
|
||||
raw = run(
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
"color.ui=false",
|
||||
"diff",
|
||||
"--unified=0",
|
||||
"--no-ext-diff",
|
||||
"--",
|
||||
path,
|
||||
]
|
||||
).stdout.decode("utf-8")
|
||||
starts = [match.start() for match in re.finditer(r"^@@ ", raw, re.MULTILINE)]
|
||||
hunks: list[Hunk] = []
|
||||
for index, start in enumerate(starts):
|
||||
end = starts[index + 1] if index + 1 < len(starts) else len(raw)
|
||||
chunk = raw[start:end]
|
||||
lines = chunk.splitlines()
|
||||
if not lines:
|
||||
raise AssemblyError(f"empty diff hunk: {path}")
|
||||
header = lines[0]
|
||||
match = HUNK_HEADER.match(header)
|
||||
if match is None:
|
||||
raise AssemblyError(f"unparseable hunk header for {path}: {header}")
|
||||
old_start = int(match.group(1))
|
||||
old_count = int(match.group(2) or "1")
|
||||
new_start = int(match.group(3))
|
||||
new_count = int(match.group(4) or "1")
|
||||
removed: list[str] = []
|
||||
added: list[str] = []
|
||||
for line in lines[1:]:
|
||||
if line.startswith("-"):
|
||||
removed.append(line[1:])
|
||||
elif line.startswith("+"):
|
||||
added.append(line[1:])
|
||||
elif line == "\\ No newline at end of file":
|
||||
raise AssemblyError(f"no-final-newline hunk is unsupported: {path} {header}")
|
||||
else:
|
||||
raise AssemblyError(
|
||||
f"unexpected zero-context hunk line for {path} {header}: {line!r}"
|
||||
)
|
||||
if len(removed) != old_count or len(added) != new_count:
|
||||
raise AssemblyError(
|
||||
f"hunk count mismatch for {path} {header}: "
|
||||
f"removed={len(removed)}/{old_count}, added={len(added)}/{new_count}"
|
||||
)
|
||||
hunks.append(
|
||||
Hunk(
|
||||
header=header,
|
||||
raw=chunk,
|
||||
old_start=old_start,
|
||||
old_count=old_count,
|
||||
new_start=new_start,
|
||||
new_count=new_count,
|
||||
removed=tuple(removed),
|
||||
added=tuple(added),
|
||||
)
|
||||
)
|
||||
return hunks
|
||||
|
||||
|
||||
def git_base_text(base_commit: str, path: str) -> str:
|
||||
result = run(["git", "show", f"{base_commit}:{path}"])
|
||||
try:
|
||||
return result.stdout.decode("utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise AssemblyError(f"mixed document is not UTF-8 text: {path}") from exc
|
||||
|
||||
|
||||
def selected_added_lines(hunk: Hunk, mapping: dict[str, Any]) -> list[str]:
|
||||
decision = mapping.get("decision")
|
||||
if decision == "include":
|
||||
return list(hunk.added)
|
||||
if decision == "exclude":
|
||||
return []
|
||||
if decision != "split-added-lines":
|
||||
raise AssemblyError(f"unknown hunk decision: {decision!r}")
|
||||
if hunk.old_count != 0 or hunk.removed:
|
||||
raise AssemblyError(
|
||||
f"split-added-lines only supports pure insertions: {hunk.header}"
|
||||
)
|
||||
include = mapping.get("include_added_line_numbers")
|
||||
exclude = mapping.get("exclude_added_line_numbers")
|
||||
if not isinstance(include, list) or not isinstance(exclude, list):
|
||||
raise AssemblyError(f"split hunk needs include/exclude indexes: {hunk.header}")
|
||||
include_set = set(include)
|
||||
exclude_set = set(exclude)
|
||||
expected = set(range(1, len(hunk.added) + 1))
|
||||
if include_set & exclude_set or include_set | exclude_set != expected:
|
||||
raise AssemblyError(
|
||||
f"split indexes must partition every added line: {hunk.header}"
|
||||
)
|
||||
if any(not isinstance(index, int) for index in include + exclude):
|
||||
raise AssemblyError(f"split indexes must be integers: {hunk.header}")
|
||||
return [line for index, line in enumerate(hunk.added, 1) if index in include_set]
|
||||
|
||||
|
||||
def assemble_document(
|
||||
base_commit: str,
|
||||
document: dict[str, Any],
|
||||
) -> tuple[str, dict[str, int]]:
|
||||
path = canonical_path(document.get("path"))
|
||||
current_hunks = parse_hunks(path)
|
||||
mapped = document.get("hunks")
|
||||
if not isinstance(mapped, list):
|
||||
raise AssemblyError(f"document.hunks must be an array: {path}")
|
||||
if len(mapped) != len(current_hunks):
|
||||
raise AssemblyError(
|
||||
f"hunk coverage drift for {path}: mapped={len(mapped)}, current={len(current_hunks)}"
|
||||
)
|
||||
|
||||
base_text = git_base_text(base_commit, path)
|
||||
if not base_text.endswith("\n"):
|
||||
raise AssemblyError(f"mixed document must end in newline: {path}")
|
||||
base_lines = base_text[:-1].split("\n")
|
||||
output: list[str] = []
|
||||
cursor = 0
|
||||
counts = {"include": 0, "exclude": 0, "split-added-lines": 0}
|
||||
|
||||
for index, (hunk, mapping) in enumerate(zip(current_hunks, mapped), 1):
|
||||
if not isinstance(mapping, dict):
|
||||
raise AssemblyError(f"hunk mapping must be an object: {path} #{index}")
|
||||
if mapping.get("header") != hunk.header:
|
||||
raise AssemblyError(
|
||||
f"hunk header drift for {path} #{index}: "
|
||||
f"mapped={mapping.get('header')!r}, current={hunk.header!r}"
|
||||
)
|
||||
if mapping.get("sha256") != hunk.sha256:
|
||||
raise AssemblyError(
|
||||
f"hunk hash drift for {path} {hunk.header}: "
|
||||
f"mapped={mapping.get('sha256')}, current={hunk.sha256}"
|
||||
)
|
||||
rationale = mapping.get("rationale")
|
||||
if not isinstance(rationale, str) or not rationale.strip():
|
||||
raise AssemblyError(f"hunk rationale is empty: {path} {hunk.header}")
|
||||
decision = mapping.get("decision")
|
||||
if decision not in counts:
|
||||
raise AssemblyError(f"invalid decision for {path}: {decision!r}")
|
||||
counts[decision] += 1
|
||||
|
||||
if decision == "exclude":
|
||||
continue
|
||||
old_index = hunk.old_start if hunk.old_count == 0 else hunk.old_start - 1
|
||||
if old_index < cursor:
|
||||
raise AssemblyError(f"selected hunks overlap in {path}: {hunk.header}")
|
||||
if hunk.old_count:
|
||||
actual_removed = base_lines[old_index : old_index + hunk.old_count]
|
||||
if actual_removed != list(hunk.removed):
|
||||
raise AssemblyError(
|
||||
f"base content does not match selected hunk in {path}: {hunk.header}"
|
||||
)
|
||||
output.extend(base_lines[cursor:old_index])
|
||||
output.extend(selected_added_lines(hunk, mapping))
|
||||
cursor = old_index + hunk.old_count
|
||||
|
||||
output.extend(base_lines[cursor:])
|
||||
return "\n".join(output) + "\n", counts
|
||||
|
||||
|
||||
def materialize_head(base_commit: str, destination: Path) -> None:
|
||||
archive = destination.parent / f"{destination.name}.zip"
|
||||
run(
|
||||
[
|
||||
"git",
|
||||
"archive",
|
||||
"--format=zip",
|
||||
f"--output={archive}",
|
||||
base_commit,
|
||||
]
|
||||
)
|
||||
destination.mkdir(parents=True, exist_ok=True)
|
||||
with zipfile.ZipFile(archive) as payload:
|
||||
payload.extractall(destination)
|
||||
|
||||
|
||||
def related_payload_paths(
|
||||
manifest: dict[str, Any], hunk_map: dict[str, Any]
|
||||
) -> list[str]:
|
||||
classifications = manifest.get("classifications")
|
||||
if not isinstance(classifications, dict):
|
||||
raise AssemblyError("manifest classifications missing")
|
||||
related = classifications.get("related")
|
||||
if not isinstance(related, list):
|
||||
raise AssemblyError("manifest related groups missing")
|
||||
payload = hunk_map.get("payload")
|
||||
if not isinstance(payload, dict):
|
||||
raise AssemblyError("hunk map payload config missing")
|
||||
excluded_groups = set(payload.get("excluded_related_group_ids", []))
|
||||
excluded_paths = {
|
||||
canonical_path(path) for path in payload.get("excluded_paths", [])
|
||||
}
|
||||
selected: set[str] = set()
|
||||
seen_group_ids: set[str] = set()
|
||||
for group in related:
|
||||
if not isinstance(group, dict):
|
||||
raise AssemblyError("related group must be an object")
|
||||
group_id = group.get("id")
|
||||
if not isinstance(group_id, str) or not group_id:
|
||||
raise AssemblyError("related group id missing")
|
||||
seen_group_ids.add(group_id)
|
||||
if group_id in excluded_groups:
|
||||
continue
|
||||
paths = group.get("paths")
|
||||
if not isinstance(paths, list):
|
||||
raise AssemblyError(f"related group paths missing: {group_id}")
|
||||
selected.update(canonical_path(path) for path in paths)
|
||||
unknown_groups = excluded_groups - seen_group_ids
|
||||
if unknown_groups:
|
||||
raise AssemblyError(f"unknown excluded related groups: {sorted(unknown_groups)}")
|
||||
return sorted(selected - excluded_paths)
|
||||
|
||||
|
||||
def copy_related_files(paths: list[str], release_tree: Path) -> None:
|
||||
for path in paths:
|
||||
source = REPO_ROOT / path
|
||||
if not source.is_file():
|
||||
raise AssemblyError(f"related payload source missing: {path}")
|
||||
target = release_tree / path
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(source, target)
|
||||
|
||||
|
||||
def create_openapi_tool_link(temp_web: Path) -> Path:
|
||||
source_modules = REPO_ROOT / "apps/web/node_modules"
|
||||
source_tool = source_modules / "openapi-typescript"
|
||||
source_cmd = source_modules / ".bin/openapi-typescript.cmd"
|
||||
if not source_tool.is_dir() or not source_cmd.is_file():
|
||||
raise AssemblyError("openapi-typescript is unavailable in apps/web/node_modules")
|
||||
temp_modules = temp_web / "node_modules"
|
||||
temp_bin = temp_modules / ".bin"
|
||||
temp_bin.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(source_cmd, temp_bin / source_cmd.name)
|
||||
junction = temp_modules / "openapi-typescript"
|
||||
result = run(
|
||||
["cmd.exe", "/d", "/c", "mklink", "/J", str(junction), str(source_tool)],
|
||||
allow_failure=True,
|
||||
)
|
||||
if result.returncode:
|
||||
detail = result.stderr.decode("utf-8", errors="replace").strip()
|
||||
raise AssemblyError(f"cannot create temporary openapi-typescript junction: {detail}")
|
||||
return junction
|
||||
|
||||
|
||||
def regenerate_api(
|
||||
release_tree: Path,
|
||||
generation: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
path = canonical_path(generation.get("path"))
|
||||
temp_web = release_tree / "apps/web"
|
||||
junction = create_openapi_tool_link(temp_web)
|
||||
env = os.environ.copy()
|
||||
env["PYTHONUTF8"] = "1"
|
||||
try:
|
||||
generate_command = generation.get("generate_command")
|
||||
check_command = generation.get("check_command")
|
||||
if generate_command != "npm.cmd run generate:api-types":
|
||||
raise AssemblyError("generated file command is not the canonical npm generator")
|
||||
if check_command != "npm.cmd run check:api-types":
|
||||
raise AssemblyError("generated file check is not canonical")
|
||||
run(["npm.cmd", "run", "generate:api-types"], cwd=temp_web, env=env)
|
||||
generated = release_tree / path
|
||||
if not generated.is_file():
|
||||
raise AssemblyError(f"API generator did not create {path}")
|
||||
first_hash = hashlib.sha256(generated.read_bytes()).hexdigest()
|
||||
run(["npm.cmd", "run", "check:api-types"], cwd=temp_web, env=env)
|
||||
second_hash = hashlib.sha256(generated.read_bytes()).hexdigest()
|
||||
if first_hash != second_hash:
|
||||
raise AssemblyError("check:api-types changed generated output")
|
||||
text = generated.read_text(encoding="utf-8")
|
||||
missing = [
|
||||
marker
|
||||
for marker in generation.get("required_markers", [])
|
||||
if marker not in text
|
||||
]
|
||||
forbidden = [
|
||||
marker
|
||||
for marker in generation.get("forbidden_markers", [])
|
||||
if marker in text
|
||||
]
|
||||
if missing:
|
||||
raise AssemblyError(f"regenerated API is missing required markers: {missing}")
|
||||
if forbidden:
|
||||
raise AssemblyError(f"regenerated API contains unrelated markers: {forbidden}")
|
||||
return {
|
||||
"path": path,
|
||||
"sha256": first_hash,
|
||||
"bytes": len(generated.read_bytes()),
|
||||
"required_markers": len(generation.get("required_markers", [])),
|
||||
"forbidden_markers_absent": len(generation.get("forbidden_markers", [])),
|
||||
"official_generate": True,
|
||||
"official_check": True,
|
||||
}
|
||||
finally:
|
||||
if junction.exists():
|
||||
os.rmdir(junction)
|
||||
|
||||
|
||||
def text_lines(path: Path) -> list[str]:
|
||||
payload = path.read_bytes()
|
||||
if b"\0" in payload:
|
||||
raise AssemblyError(f"binary release payload is unsupported: {path}")
|
||||
try:
|
||||
text = payload.decode("utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise AssemblyError(f"release payload is not UTF-8: {path}") from exc
|
||||
# ``git archive`` may materialize text according to checkout attributes and
|
||||
# the source worktree may use a different EOL policy. A release patch must
|
||||
# compare against the canonical Git blobs, not one machine's checkout EOL.
|
||||
# Normalizing before ``difflib`` keeps the patch applicable both to the
|
||||
# canonical index and to CRLF/LF clean worktrees handled by ``git apply``.
|
||||
canonical = text.replace("\r\n", "\n").replace("\r", "\n")
|
||||
return canonical.splitlines(keepends=True)
|
||||
|
||||
|
||||
def build_patch(base_tree: Path, release_tree: Path, paths: list[str]) -> tuple[str, int]:
|
||||
chunks: list[str] = []
|
||||
changed_files = 0
|
||||
for path in sorted(set(paths)):
|
||||
base_path = base_tree / path
|
||||
release_path = release_tree / path
|
||||
if not release_path.is_file():
|
||||
raise AssemblyError(f"assembled release file missing: {path}")
|
||||
old_lines = text_lines(base_path) if base_path.is_file() else []
|
||||
new_lines = text_lines(release_path)
|
||||
if old_lines == new_lines:
|
||||
raise AssemblyError(f"declared release payload has no diff from base: {path}")
|
||||
changed_files += 1
|
||||
chunks.append(f"diff --git a/{path} b/{path}\n")
|
||||
if not base_path.exists():
|
||||
chunks.append("new file mode 100644\n")
|
||||
from_file = "/dev/null"
|
||||
else:
|
||||
from_file = f"a/{path}"
|
||||
chunks.extend(
|
||||
difflib.unified_diff(
|
||||
old_lines,
|
||||
new_lines,
|
||||
fromfile=from_file,
|
||||
tofile=f"b/{path}",
|
||||
n=3,
|
||||
lineterm="\n",
|
||||
)
|
||||
)
|
||||
return "".join(chunks), changed_files
|
||||
|
||||
|
||||
def validate_mixed_coverage(
|
||||
manifest: dict[str, Any], hunk_map: dict[str, Any]
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
classifications = manifest.get("classifications", {})
|
||||
mixed = classifications.get("mixed") if isinstance(classifications, dict) else None
|
||||
if not isinstance(mixed, list):
|
||||
raise AssemblyError("manifest mixed classification missing")
|
||||
manifest_paths: set[str] = set()
|
||||
for entry in mixed:
|
||||
if not isinstance(entry, dict) or entry.get("disposition") != "hunk-review-required":
|
||||
raise AssemblyError("every manifest mixed entry must remain hunk-review-required")
|
||||
paths = entry.get("paths")
|
||||
if not isinstance(paths, list):
|
||||
raise AssemblyError("manifest mixed paths missing")
|
||||
manifest_paths.update(canonical_path(path) for path in paths)
|
||||
|
||||
generated = hunk_map.get("generated_files")
|
||||
documents = hunk_map.get("documents")
|
||||
if not isinstance(generated, list) or not isinstance(documents, list):
|
||||
raise AssemblyError("hunk map generated_files/documents must be arrays")
|
||||
mapped_paths = {
|
||||
canonical_path(item.get("path"))
|
||||
for item in [*generated, *documents]
|
||||
if isinstance(item, dict)
|
||||
}
|
||||
if manifest_paths != mapped_paths:
|
||||
raise AssemblyError(
|
||||
f"mixed path coverage drift: manifest={sorted(manifest_paths)}, "
|
||||
f"map={sorted(mapped_paths)}"
|
||||
)
|
||||
return generated, documents
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
hunk_map_path = args.map if args.map.is_absolute() else REPO_ROOT / args.map
|
||||
before: dict[str, str | None] = {}
|
||||
output_path = ""
|
||||
prior_output: bytes | None = None
|
||||
prior_output_existed = False
|
||||
output_replaced = False
|
||||
try:
|
||||
hunk_map = load_json(hunk_map_path)
|
||||
if hunk_map.get("schema_version") != "1.0":
|
||||
raise AssemblyError("hunk map schema_version must be 1.0")
|
||||
base_commit = hunk_map.get("base_commit")
|
||||
if not isinstance(base_commit, str) or current_head() != base_commit:
|
||||
raise AssemblyError(
|
||||
f"HEAD drift: expected {base_commit}, current {current_head()}"
|
||||
)
|
||||
manifest_path = REPO_ROOT / canonical_path(hunk_map.get("manifest"))
|
||||
manifest = load_json(manifest_path)
|
||||
generated, documents = validate_mixed_coverage(manifest, hunk_map)
|
||||
if len(generated) != 1:
|
||||
raise AssemblyError("exactly one generated mixed file is expected")
|
||||
output_path = canonical_path(hunk_map.get("output_patch"))
|
||||
destination = REPO_ROOT / output_path
|
||||
prior_output_existed = destination.is_file()
|
||||
if prior_output_existed:
|
||||
prior_output = destination.read_bytes()
|
||||
before = snapshot_worktree()
|
||||
|
||||
document_outputs: dict[str, str] = {}
|
||||
document_counts: dict[str, dict[str, int]] = {}
|
||||
for document in documents:
|
||||
if not isinstance(document, dict):
|
||||
raise AssemblyError("document mapping must be an object")
|
||||
path = canonical_path(document.get("path"))
|
||||
assembled, counts = assemble_document(base_commit, document)
|
||||
document_outputs[path] = assembled
|
||||
document_counts[path] = counts
|
||||
|
||||
payload_paths = related_payload_paths(manifest, hunk_map)
|
||||
mixed_paths = [
|
||||
canonical_path(item.get("path"))
|
||||
for item in [*generated, *documents]
|
||||
]
|
||||
release_paths = sorted(set(payload_paths) | set(mixed_paths))
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="vignette-outcome-release-") as temp_name:
|
||||
temp_root = Path(temp_name)
|
||||
base_tree = temp_root / "base"
|
||||
release_tree = temp_root / "release"
|
||||
materialize_head(base_commit, base_tree)
|
||||
materialize_head(base_commit, release_tree)
|
||||
copy_related_files(payload_paths, release_tree)
|
||||
for path, content in document_outputs.items():
|
||||
target = release_tree / path
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(content, encoding="utf-8", newline="\n")
|
||||
api_report = regenerate_api(release_tree, generated[0])
|
||||
patch_text, changed_files = build_patch(
|
||||
base_tree, release_tree, release_paths
|
||||
)
|
||||
if not patch_text:
|
||||
raise AssemblyError("assembled patch is empty")
|
||||
candidate_patch = temp_root / "release-candidate.patch"
|
||||
candidate_patch.write_text(patch_text, encoding="utf-8", newline="\n")
|
||||
apply_check = run(
|
||||
[
|
||||
"git",
|
||||
"apply",
|
||||
"--check",
|
||||
"--whitespace=nowarn",
|
||||
str(candidate_patch),
|
||||
],
|
||||
cwd=base_tree,
|
||||
)
|
||||
if apply_check.returncode != 0:
|
||||
raise AssemblyError("git apply --check failed")
|
||||
|
||||
# Prove the same patch against a canonical clean index as well.
|
||||
# This catches checkout-only patches whose CRLF hunk context works
|
||||
# on Windows but fails in CI/Linux or with ``git apply --cached``.
|
||||
canonical_index = temp_root / "canonical.index"
|
||||
index_env = os.environ.copy()
|
||||
index_env["GIT_INDEX_FILE"] = str(canonical_index)
|
||||
run(["git", "read-tree", base_commit], env=index_env)
|
||||
cached_apply_check = run(
|
||||
[
|
||||
"git",
|
||||
"apply",
|
||||
"--cached",
|
||||
"--check",
|
||||
"--whitespace=nowarn",
|
||||
str(candidate_patch),
|
||||
],
|
||||
env=index_env,
|
||||
)
|
||||
if cached_apply_check.returncode != 0:
|
||||
raise AssemblyError("canonical-index git apply --cached --check failed")
|
||||
|
||||
# Do not replace the last verified artifact until every validation
|
||||
# and the source snapshot guard passes. A concurrent edit must
|
||||
# leave the previous patch intact instead of publishing a partial
|
||||
# or stale candidate.
|
||||
verify_worktree_unchanged(before, output_path)
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
# ``tempfile`` may live on C: while the repository lives on D:.
|
||||
# Windows cannot atomically replace across volumes, so first copy
|
||||
# the fully verified candidate beside the destination and only
|
||||
# then perform the same-volume atomic swap.
|
||||
publish_candidate = destination.with_name(
|
||||
f".{destination.name}.{os.getpid()}.tmp"
|
||||
)
|
||||
try:
|
||||
shutil.copyfile(candidate_patch, publish_candidate)
|
||||
os.replace(publish_candidate, destination)
|
||||
finally:
|
||||
if publish_candidate.exists():
|
||||
publish_candidate.unlink()
|
||||
output_replaced = True
|
||||
|
||||
verify_worktree_unchanged(before, output_path)
|
||||
patch_bytes = (REPO_ROOT / output_path).read_bytes()
|
||||
if b"\r\n" in patch_bytes or b"\r" in patch_bytes:
|
||||
raise AssemblyError("release patch must use canonical LF-only line endings")
|
||||
report = {
|
||||
"ok": True,
|
||||
"base_commit": base_commit,
|
||||
"output_patch": output_path,
|
||||
"patch_sha256": hashlib.sha256(patch_bytes).hexdigest(),
|
||||
"patch_bytes": len(patch_bytes),
|
||||
"patch_files": changed_files,
|
||||
"api_generation": api_report,
|
||||
"document_hunks": document_counts,
|
||||
"git_apply_check": "passed-on-clean-temporary-head",
|
||||
"git_apply_cached_check": "passed-on-canonical-clean-index",
|
||||
"patch_line_endings": "lf-only",
|
||||
"artifact_publish": "atomic-replace-after-all-guards",
|
||||
"source_worktree": "unchanged-except-output-patch",
|
||||
}
|
||||
if args.json:
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))
|
||||
else:
|
||||
print(
|
||||
"PASS outcome-os release patch: "
|
||||
f"files={changed_files}, bytes={len(patch_bytes)}, "
|
||||
f"sha256={report['patch_sha256']}; git apply --check passed"
|
||||
)
|
||||
return 0
|
||||
except (AssemblyError, OSError, zipfile.BadZipFile) as exc:
|
||||
if output_replaced and output_path:
|
||||
destination = REPO_ROOT / output_path
|
||||
if prior_output_existed and prior_output is not None:
|
||||
destination.write_bytes(prior_output)
|
||||
elif destination.exists():
|
||||
destination.unlink()
|
||||
if before and output_path:
|
||||
try:
|
||||
verify_worktree_unchanged(before, output_path)
|
||||
except AssemblyError as mutation_error:
|
||||
exc = AssemblyError(f"{exc}; {mutation_error}")
|
||||
report = {"ok": False, "error": str(exc)}
|
||||
if args.json:
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))
|
||||
else:
|
||||
print(f"FAIL outcome-os release patch: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue