#!/usr/bin/env python3 """Read-only CV181X Release/default/PSP configuration inventory and graph audit.""" from __future__ import annotations import argparse import hashlib import json import re import subprocess from functools import lru_cache from pathlib import Path def digest(path: Path) -> str: return hashlib.sha256(path.read_bytes()).hexdigest() def git_provenance(root: Path | None): if root is None: return None def git(*args): return subprocess.run(["git", "-C", str(root), *args], capture_output=True, text=True, check=True).stdout.strip() return {"path": str(root.resolve()), "commit": git("rev-parse", "HEAD"), "dirty_paths": git("status", "--short", "--untracked-files=all").splitlines()} def load(path: Path): if not path.is_file() or path.is_symlink(): raise ValueError(f"需要普通 JSON 文件: {path}") return json.loads(path.read_text(encoding="utf-8")) def leaves(value, prefix=""): if isinstance(value, dict): for key, item in value.items(): yield from leaves(item, f"{prefix}.{key}" if prefix else key) elif isinstance(value, list): for index, item in enumerate(value): yield from leaves(item, f"{prefix}[{index}]") else: yield prefix, value def config_files(profile: Path, root_name: str): paths = [profile / root_name, *sorted((profile / "configs").glob("*.json"))] if root_name == "profile.json": paths.append(profile / "AiPlugin.json") if not paths[0].is_file() or not (profile / "configs").is_dir(): raise ValueError(f"Release profile 不完整: {profile}") return {path.relative_to(profile).as_posix(): path for path in paths if path.is_file()} @lru_cache(maxsize=2) def source_index(source_root: Path, profile_name: str) -> dict[str, list[str]]: index: dict[str, list[str]] = {} pattern = re.compile(r'\[\s*"([^"]+)"\s*\]') chip_root = source_root / "chip/cvitek/cv181x" build = source_root / "profile" / profile_name / "build.cmake" selected = set() if build.is_file(): build_text = build.read_text(encoding="utf-8", errors="replace") for variant in re.findall(r'aux_source_directory\([^\n]*/chip/\$\{ChipManufactory\}/\$\{ChipSerial\}/([^\s)]+)', build_text): selected.add(profile_name if variant == "${Profile}" else variant) paths = set(chip_root.glob("*.cpp")) for variant in selected: paths.update((chip_root / variant).glob("*.cpp")) for path in sorted(paths): for number, line in enumerate(path.read_text(encoding="utf-8", errors="replace").splitlines(), 1): for key in pattern.findall(line): index.setdefault(key, []).append(f"{path.relative_to(source_root)}:{number}") return index def source_hits(source_root: Path | None, profile_name: str, key: str) -> list[str]: return source_index(source_root, profile_name).get(key, [])[:12] if source_root is not None else [] def profile_inventory(profile: Path, root_name: str, source_profile: Path | None, source_root: Path | None): result = {} drift = [] for relative, path in config_files(profile, root_name).items(): source_path = source_profile / relative if source_profile and relative != "AiPlugin.json" else None if source_path is not None and (not source_path.is_file() or digest(path) != digest(source_path)): drift.append(relative) rows = [] for field, default in leaves(load(path)): key = re.split(r"\.|\[", field)[-1] rows.append({ "field": field, "release_default": default, "type": type(default).__name__, "source_hits_hint_only": source_hits(source_root, profile.name, key), }) result[relative] = {"sha256": digest(path), "fields": rows} return result, drift def check_graph(media_system, media_files, ai_files): errors = [] if not isinstance(media_system, list) or not media_system: return ["system.json 必须包含至少一个 System 通道"] for system_index, system in enumerate(media_system): groups = {g["vpssGrp"]: g for g in system.get("vpss", {}).get("group", [])} pools = len(system.get("vb", [])) for group_id, group in groups.items(): for attach in group.get("attachPool", []): if attach.get("chn", -1) not in range(len(group.get("chn", []))): errors.append(f"System[{system_index}] VPSS {group_id} attachPool.chn 不存在") if attach.get("pool", -1) not in range(pools): errors.append(f"System[{system_index}] VPSS {group_id} attachPool.pool 不存在") for bind in group.get("bind", []): parent = groups.get(bind.get("group")) if parent is None or bind.get("chn", -1) not in range(len(parent.get("chn", []))): errors.append(f"System[{system_index}] VPSS {group_id} bind 来源不存在") for relative in ("configs/venc.json", "configs/snap.json", "configs/osd.json", "configs/draw.json"): for index, item in enumerate(media_files.get(relative, [])): attr = item.get("attr", item) group_id = attr.get("VpssGrp") channel = attr.get("VpssChn") if group_id is not None and (group_id not in groups or channel not in range(len(groups[group_id].get("chn", [])))): errors.append(f"{relative}[{index}] VPSS {group_id}/{channel} 不存在") enabled = ai_files.get("configs/config.json", {}) for relative, items in ai_files.items(): if relative == "configs/config.json" or not isinstance(items, list): continue module = Path(relative).stem if enabled.get(module) is not True: continue for index, item in enumerate(items): if not isinstance(item, dict) or "vpssGrp" not in item: continue group_id, channel = item["vpssGrp"], item.get("vpssChn") if group_id not in groups or channel not in range(len(groups[group_id].get("chn", []))): errors.append(f"{relative}[{index}] VPSS {group_id}/{channel} 不存在") return errors def model_references(ai_files, ai_meta, psp=None): enabled = ai_files.get("configs/config.json", {}) declared = set(ai_meta.get("runtime", {}).get("file", [])) result = [] for relative, items in ai_files.items(): if not relative.startswith("configs/") or not isinstance(items, list): continue module = Path(relative).stem if enabled.get(module) is not True: continue for index, item in enumerate(items): if not isinstance(item, dict): continue for key, value in item.items(): if not key.endswith("ModelPath") and key != "modelPath": continue if not isinstance(value, str) or not value: continue local = (psp / "AiPlugin" / value).is_file() if psp is not None else False result.append({"module": module, "channel": index, "field": key, "model": value, "release_runtime_declared": value in declared, "psp_file_present": local, "requires_source_or_packaging_evidence": value not in declared and not local}) return result def audit(media_profile: Path, ai_profile: Path, media_source: Path | None = None, ai_source: Path | None = None, psp: Path | None = None): media_profile, ai_profile = media_profile.resolve(), ai_profile.resolve() if not media_profile.parent.name.startswith("EmxMedia-") or not ai_profile.parent.name.startswith("EmxAiPlugin-"): raise ValueError("必须指定精确 Release 版本下的 profile,不能指定源码 profile 或 Debug") media_root = media_profile.parent.name ai_root = ai_profile.parent.name media_meta = load(media_profile / "configs.json") ai_meta = load(ai_profile / "profile.json") ai_package = load(ai_profile / "AiPlugin.json") if any(meta.get("ChipSerial", "").lower() != "cv181x" for meta in (media_meta, ai_meta)): raise ValueError("当前只支持 CV181X Release") media_source_profile = media_source / "profile" / media_profile.name if media_source else None ai_source_profile = ai_source / "profile" / ai_profile.name if ai_source else None media_catalog, media_drift = profile_inventory(media_profile, "configs.json", media_source_profile, media_source) ai_catalog, ai_drift = profile_inventory(ai_profile, "profile.json", ai_source_profile, ai_source) errors = [] for key in ("EmxSdkVersion", "ChipManufactory", "ChipSerial", "ChipSdkVersion", "Cross"): if media_meta.get(key) != ai_meta.get(key): errors.append(f"Media/AI {key} 不一致") if ai_package.get("profile") != ai_profile.name or ai_package.get("version") != ai_root.removeprefix("EmxAiPlugin-"): errors.append("AiPlugin.json 与所选 Release/profile 不一致") media_files = {rel: load(path) for rel, path in config_files(media_profile, "configs.json").items()} ai_files = {rel: load(path) for rel, path in config_files(ai_profile, "profile.json").items()} errors += check_graph(media_files["configs/system.json"], media_files, ai_files) candidate = [] model_refs = model_references(ai_files, ai_meta, psp) if psp is not None: psp = psp.resolve() psp_json = load(psp / "psp.json") for key, expected in (("EmxMediaVersion", media_root), ("EmxMediaProfile", media_profile.name), ("EmxAiPluginVersion", ai_root), ("EmxAiPluginProfile", ai_profile.name)): if psp_json.get(key) != expected: errors.append(f"psp.json {key} 与所选 Release 不一致") mapping = (("media", media_files, media_catalog, psp / "configs/media/scene/default"), ("ai", ai_files, ai_catalog, psp / "AiPlugin/configs")) effective = {"media": dict(media_files), "ai": dict(ai_files)} for kind, defaults, catalog, target in mapping: for relative, baseline in defaults.items(): if not relative.startswith("configs/"): continue path = target / Path(relative).name if not path.is_file(): if kind == "ai": path = psp / "AiPlugin/configs_ro" / Path(relative).name if not path.is_file(): candidate.append({"kind": kind, "file": relative, "state": "not_in_psp_check_effective_source"}) continue current = load(path) effective[kind][relative] = current baseline_fields, current_fields = dict(leaves(baseline)), dict(leaves(current)) for field in sorted(baseline_fields.keys() | current_fields.keys()): if field not in baseline_fields or field not in current_fields or baseline_fields[field] != current_fields[field]: candidate.append({"kind": kind, "file": str(path), "field": field, "release_default": baseline_fields.get(field), "candidate": current_fields.get(field), "state": "changed_or_missing_requires_evidence"}) errors += check_graph(effective["media"]["configs/system.json"], effective["media"], effective["ai"]) model_refs = model_references(effective["ai"], ai_meta, psp) return { "schema_version": 1, "scope": "read_only_static_audit_not_hardware_or_runtime_validation", "media_release": str(media_profile), "ai_release": str(ai_profile), "media_sha256": digest(media_profile / "configs.json"), "ai_sha256": digest(ai_profile / "profile.json"), "source_release_drift": {"media": media_drift, "ai": ai_drift}, "source_git": {"media": git_provenance(media_source), "ai": git_provenance(ai_source)}, "field_counts": {"media": sum(len(x["fields"]) for x in media_catalog.values()), "ai": sum(len(x["fields"]) for x in ai_catalog.values())}, "catalog": {"media": media_catalog, "ai": ai_catalog}, "candidate_differences": candidate, "enabled_ai_model_references": model_refs, "errors": errors, "passed_static_cross_checks": not errors, } def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--media-release-profile", type=Path, required=True) parser.add_argument("--ai-release-profile", type=Path, required=True) parser.add_argument("--media-source", type=Path) parser.add_argument("--ai-source", type=Path) parser.add_argument("--psp", type=Path) args = parser.parse_args() try: result = audit(args.media_release_profile, args.ai_release_profile, args.media_source, args.ai_source, args.psp) except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError, subprocess.CalledProcessError) as exc: parser.exit(2, f"audit failed: {exc}\n") print(json.dumps(result, ensure_ascii=False, indent=2)) if result["errors"]: parser.exit(1) if __name__ == "__main__": main()