#!/usr/bin/env python3 """cangjie_common.py — 仓颉确定性脚本的共享工具(路线 C:纯本地、不调模型)。 提供:frontmatter 解析、哈希、确定性缓存键、per-run workdir、writer lock、 staging + 原子发布、发布哈希登记与本地手改检测(方案 §4.4/§4.6.3/§13A 非功能矩阵)。 """ from __future__ import annotations import hashlib import json import os import shutil import time import uuid from pathlib import Path import yaml TOOL_VERSION = "cangjie-tools v2.5.0" # ---------- 基础 IO ---------- def sha256_bytes(data: bytes) -> str: return hashlib.sha256(data).hexdigest() def sha256_file(path: Path) -> str: return sha256_bytes(path.read_bytes()) def sha256_text(text: str) -> str: return sha256_bytes(text.encode("utf-8")) def load_yaml(path: Path) -> dict: data = yaml.safe_load(path.read_text(encoding="utf-8")) if not isinstance(data, dict): raise ValueError(f"{path}: 期望 YAML mapping") return data def dump_json(path: Path, data) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") def load_json(path: Path): return json.loads(path.read_text(encoding="utf-8")) # ---------- frontmatter ---------- def split_frontmatter(text: str) -> tuple[dict, str]: """返回 (frontmatter dict, body)。无 frontmatter 时返回 ({}, 原文)。""" if text.startswith("---"): end = text.find("\n---", 3) if end != -1: fm = yaml.safe_load(text[3:end]) if isinstance(fm, dict): return fm, text[end + 4 :].lstrip("\n") return {}, text # ---------- 确定性缓存(方案 §4.4 A 类) ---------- def deterministic_cache_key(stage_name: str, implementation_version: str, stage_schema_version: str, ordered_input_hashes: list[str], normalized_parameters: dict) -> str: payload = "\n".join([ stage_name, implementation_version, stage_schema_version, *ordered_input_hashes, json.dumps(normalized_parameters, ensure_ascii=False, sort_keys=True), ]) return sha256_text(payload) def cache_lookup(cache_root: Path, stage: str, key: str) -> Path | None: p = cache_root / stage / key return p if p.is_dir() else None def cache_store(cache_root: Path, stage: str, key: str, files: dict[str, bytes]) -> Path: """临时目录写入后原子 rename,避免半写缓存。""" final = cache_root / stage / key if final.exists(): return final tmp = cache_root / stage / f".tmp-{key}-{uuid.uuid4().hex[:8]}" tmp.mkdir(parents=True) for rel, data in files.items(): f = tmp / rel f.parent.mkdir(parents=True, exist_ok=True) f.write_bytes(data) try: tmp.rename(final) except OSError: shutil.rmtree(tmp, ignore_errors=True) # 并发下另一个 writer 已完成 return final # ---------- run workdir / writer lock(方案 §15.17) ---------- def new_run_id() -> str: return time.strftime("run-%Y%m%d-%H%M%S-") + uuid.uuid4().hex[:6] def create_run_workdir(sidecar: Path, run_id: str | None = None) -> Path: run_id = run_id or new_run_id() workdir = sidecar / "runs" / run_id workdir.mkdir(parents=True, exist_ok=False) return workdir class WriterLock: """同一目标 pack 同时只允许一个 writer。O_EXCL 创建锁文件,崩溃后可依据 pid/时间人工清理。""" def __init__(self, target: Path): self.lock_path = target.with_name(target.name + ".cangjie-lock") def __enter__(self): try: fd = os.open(self.lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY) except FileExistsError: raise SystemExit( f"目标已被另一个 writer 锁定: {self.lock_path}\n" f"若确认无并发运行,删除该锁文件后重试。" ) os.write(fd, f"pid={os.getpid()} time={time.strftime('%F %T')}\n".encode()) os.close(fd) return self def __exit__(self, *exc): self.lock_path.unlink(missing_ok=True) return False # ---------- staging + 原子发布 + 手改检测(方案 §4.6.3/§6.5) ---------- MANIFEST_NAME = "BUILD_MANIFEST.json" def collect_published_hashes(out_dir: Path) -> dict[str, str]: return { str(p.relative_to(out_dir)): sha256_file(p) for p in sorted(out_dir.rglob("*")) if p.is_file() and p.name != MANIFEST_NAME } def detect_local_edits(target: Path) -> list[str]: """比对目标目录当前文件与 BUILD_MANIFEST 发布哈希,返回被手工修改/删除的文件列表。""" manifest_path = target / MANIFEST_NAME if not manifest_path.exists(): return [] published = load_json(manifest_path).get("published_hashes", {}) edited = [] for rel, digest in published.items(): f = target / rel if not f.exists(): edited.append(f"{rel} (已删除)") elif sha256_file(f) != digest: edited.append(rel) return edited def atomic_publish(staging: Path, target: Path, manifest_extra: dict, *, allow_overwrite_edits: bool = False) -> None: """staging 校验通过后原子替换发布目录。检测到本地手改且未显式允许时中止(三选一保护)。""" edits = detect_local_edits(target) if edits and not allow_overwrite_edits: raise SystemExit( "检测到已发布目录中的本地手工修改,拒绝静默覆盖:\n - " + "\n - ".join(edits) + "\n请三选一:\n 1) --force-overwrite 丢弃本地修改\n 2) 把修改回填 Capability Bundle 后重编译\n 3) 中止(当前行为)" ) manifest = { "tool": TOOL_VERSION, "published_hashes": collect_published_hashes(staging), "note": "update/重编译前比对 published_hashes;检测到本地手改不得静默覆盖", **manifest_extra, } dump_json(staging / MANIFEST_NAME, manifest) backup = None if target.exists(): backup = target.with_name(target.name + f".prev-{uuid.uuid4().hex[:6]}") target.rename(backup) try: staging.rename(target) except OSError: if backup is not None: backup.rename(target) # 发布失败,恢复旧版本 raise if backup is not None: shutil.rmtree(backup) def snapshot_dir(src: Path, snapshots_root: Path, label: str) -> Path: """发布前快照,供 rollback 使用(RPO=0)。""" dest = snapshots_root / f"{time.strftime('%Y%m%d-%H%M%S')}-{label}" dest.parent.mkdir(parents=True, exist_ok=True) shutil.copytree(src, dest) return dest