#!/usr/bin/env python3 """Validate a project PSP without changing BSP, application, or packaging inputs.""" from __future__ import annotations import argparse import configparser import hashlib import json import os import re import subprocess import sys import tempfile from pathlib import Path, PurePosixPath from typing import Any, Iterator import validate_bsp_handoff PERSONAL_OR_TEMP_ROOTS = (Path("/home"), Path("/tmp"), Path("/var/tmp")) TEXT_PATH_SUFFIXES = {".cfg", ".conf", ".ini", ".py", ".sh"} ABSOLUTE_PATH_RE = re.compile(r"(? str: digest = hashlib.sha256() with path.open("rb") as handle: for block in iter(lambda: handle.read(1024 * 1024), b""): digest.update(block) return digest.hexdigest() def write_json_atomic(path: Path, payload: dict[str, object]) -> None: path = path.expanduser().resolve(strict=False) path.parent.mkdir(parents=True, exist_ok=True) with tempfile.NamedTemporaryFile( "w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.", delete=False ) as handle: json.dump(payload, handle, ensure_ascii=False, indent=2) handle.write("\n") temporary = Path(handle.name) os.replace(temporary, path) def load_json(path: Path) -> Any: return json.loads(path.read_text(encoding="utf-8")) def normalize_relative(value: str, field: str) -> str: pure = PurePosixPath(value) if pure.is_absolute() or ".." in pure.parts or value in {"", "."}: raise ValidationError(f"{field} 必须是仓库内精确相对路径: {value!r}") if any(character in value for character in "*?[]"): raise ValidationError(f"{field} 不允许通配符: {value!r}") return pure.as_posix() def iter_strings(value: Any, location: str = "$") -> Iterator[tuple[str, str]]: if isinstance(value, dict): for key, child in value.items(): yield from iter_strings(child, f"{location}.{key}") elif isinstance(value, list): for index, child in enumerate(value): yield from iter_strings(child, f"{location}[{index}]") elif isinstance(value, str): yield location, value def under(path: Path, root: Path) -> bool: try: path.relative_to(root) return True except ValueError: return False def iter_regular_files(root: Path) -> Iterator[Path]: """Yield repository files without following runtime or Git directory links.""" for current, dirnames, filenames in os.walk(root, followlinks=False): current_path = Path(current) dirnames[:] = sorted( name for name in dirnames if name != ".git" and not (current_path / name).is_symlink() ) for name in sorted(filenames): path = current_path / name if path.is_symlink() or not path.is_file(): continue yield path def probe_path_exists(path: Path) -> tuple[bool, str | None]: try: return path.exists(), None except OSError as exc: return False, str(exc) def collect_json(root: Path, errors: list[str]) -> dict[str, Any]: payloads: dict[str, Any] = {} for path in iter_regular_files(root): if path.suffix.lower() != ".json": continue relative = path.relative_to(root).as_posix() try: payloads[relative] = load_json(path) except (OSError, json.JSONDecodeError) as exc: errors.append(f"JSON 解析失败 {relative}: {exc}") return payloads def validate_ini(root: Path, errors: list[str]) -> int: count = 0 for path in iter_regular_files(root): if path.suffix.lower() != ".ini": continue parser = configparser.ConfigParser(interpolation=None, strict=False) try: with path.open("r", encoding="utf-8") as handle: parser.read_file(handle) count += 1 except (OSError, UnicodeError, configparser.Error) as exc: errors.append(f"INI 解析失败 {path.relative_to(root).as_posix()}: {exc}") return count def parse_expected_sensor(value: str) -> tuple[str, str]: if "=" not in value: raise argparse.ArgumentTypeError( "expected-sensor 必须为 INI_SECTION=ENUM_NAME" ) section, enum_name = (item.strip() for item in value.split("=", 1)) if not SENSOR_SECTION_RE.fullmatch(section): raise argparse.ArgumentTypeError(f"Sensor section 非法: {section!r}") if not C_IDENTIFIER_RE.fullmatch(enum_name): raise argparse.ArgumentTypeError(f"Sensor 枚举名称非法: {enum_name!r}") return section.casefold(), enum_name def cv18_sensor_family(chip: str | None) -> str | None: normalized = re.sub(r"[^A-Za-z0-9]", "", chip or "").upper() if normalized.startswith("CV181"): return "cv181x" if normalized.startswith("CV184"): return "cv184x" return None def bundled_sensor_header(family: str) -> Path: path = BUNDLED_SENSOR_HEADERS.get(family) if path is None: raise ValidationError(f"没有 {family!r} 对应的内置 Sensor 枚举基线") path = path.resolve(strict=True) if not path.is_file() or path.is_symlink(): raise ValidationError(f"内置 Sensor 枚举基线缺失或不是普通文件: {path}") return path def parse_sensor_enum(header: Path, enum_tag: str) -> dict[str, int | None]: header = header.expanduser().resolve(strict=True) if not header.is_file() or header.is_symlink(): raise ValidationError(f"Sensor 枚举头文件必须是普通文件: {header}") text = header.read_text(encoding="utf-8", errors="replace") match = re.search( rf"typedef\s+enum\s+{re.escape(enum_tag)}\s*\{{(.*?)\}}\s*" r"[A-Za-z_][A-Za-z0-9_]*\s*;", text, flags=re.DOTALL, ) if match is None: raise ValidationError(f"头文件中未找到 {enum_tag}: {header}") body = re.sub(r"/\*.*?\*/", "", match.group(1), flags=re.DOTALL) body = "\n".join( line.split("//", 1)[0] for line in body.splitlines() if not line.lstrip().startswith("#") ) result: dict[str, int | None] = {} for raw in body.split(","): item = raw.strip() if not item: continue name, separator, expression = item.partition("=") name = name.strip() if not C_IDENTIFIER_RE.fullmatch(name): continue numeric: int | None = None if separator: literal = expression.strip().rstrip("uUlL") if re.fullmatch(r"(?:0[xX][0-9A-Fa-f]+|[0-9]+)", literal): numeric = int(literal, 0) result[name] = numeric if not result: raise ValidationError(f"{enum_tag} 未解析出任何枚举项: {header}") return result def validate_sensor_baseline( psp_root: Path, expected_chip: str | None, sensor_enum_header: Path | None, expected_sensors: list[tuple[str, str]], errors: list[str], ) -> dict[str, object]: family = cv18_sensor_family(expected_chip) result: dict[str, object] = {"applicable": family is not None, "family": family} if family is None: if sensor_enum_header is not None or expected_sensors: errors.append("非 CV181X/CV184X 项目不应提供 CV18 Sensor 枚举校验参数") return result candidates = [ path for path in iter_regular_files(psp_root) if path.name == "sensor_cfg.ini" ] if len(candidates) != 1: errors.append( f"CV18 PSP 必须唯一定位一个 sensor_cfg.ini,实际 {len(candidates)} 个" ) result["config_candidates"] = [str(path) for path in candidates] return result config_path = candidates[0] result["config_path"] = str(config_path) result["config_sha256"] = sha256_file(config_path) header_source = "override" if sensor_enum_header is not None else "bundled" header = ( sensor_enum_header.expanduser().resolve(strict=True) if sensor_enum_header is not None else bundled_sensor_header(family) ) expected_filename = "sensor_list.h" if family == "cv181x" else "sensor_cfg.h" enum_tag = "_SAMPLE_SNS_TYPE_E" if family == "cv181x" else "_SNS_TYPE_E" if header.name != expected_filename: errors.append( f"{family.upper()} 必须使用 {expected_filename},实际 {header.name}" ) enum_items = parse_sensor_enum(header, enum_tag) result["enum_header"] = { "path": str(header), "sha256": sha256_file(header), "enum_tag": enum_tag, "representation": "enum-name" if family == "cv181x" else "enum-value", "source": header_source, } expected: dict[str, str] = {} for section, enum_name in expected_sensors: if section in expected: errors.append(f"expected-sensor 重复指定 section: {section}") expected[section] = enum_name parser = configparser.ConfigParser(interpolation=None, strict=False) try: with config_path.open("r", encoding="utf-8") as handle: parser.read_file(handle) except (OSError, UnicodeError, configparser.Error) as exc: errors.append(f"sensor_cfg.ini 解析失败: {exc}") return result configured = { section.casefold(): parser.get(section, "name").strip() for section in parser.sections() if SENSOR_SECTION_RE.fullmatch(section) and parser.has_option(section, "name") } if not configured: errors.append("sensor_cfg.ini 未找到带 name 的 [sensor*] section") if not expected_sensors: errors.append("CV18 Sensor 校验必须提供规格书选定的 expected-sensor") missing_expectations = sorted(set(configured) - set(expected)) unknown_sections = sorted(set(expected) - set(configured)) if missing_expectations: errors.append( "以下 Sensor section 缺少规格书选定的枚举名称: " + ", ".join(missing_expectations) ) if unknown_sections: errors.append( "expected-sensor 指向不存在或缺少 name 的 section: " + ", ".join(unknown_sections) ) checks: list[dict[str, object]] = [] for section in sorted(configured): configured_name = configured[section] enum_name = expected.get(section) item: dict[str, object] = { "section": section, "configured_name": configured_name, "expected_enum_name": enum_name, "matched": False, } if enum_name is None: checks.append(item) continue if enum_name not in enum_items: errors.append(f"{section} 规格枚举名称不在 {enum_tag} 中: {enum_name}") checks.append(item) continue if family == "cv181x": item["expected_config_value"] = enum_name item["matched"] = configured_name == enum_name if configured_name != enum_name: errors.append( f"{section}.name 必须填写枚举名称 {enum_name},实际 {configured_name}" ) else: expected_value = enum_items[enum_name] item["expected_config_value"] = ( f"0X{expected_value:08X}" if expected_value is not None else None ) try: actual_value = int(configured_name, 0) except ValueError: actual_value = None item["configured_numeric_value"] = actual_value item["matched"] = expected_value is not None and actual_value == expected_value if expected_value is None: errors.append(f"{enum_name} 在 {enum_tag} 中不是可解析的整数枚举值") elif actual_value != expected_value: errors.append( f"{section}.name 必须填写 {enum_name} 的枚举值 " f"0X{expected_value:08X},实际 {configured_name}" ) checks.append(item) result["sensors"] = checks return result def validate_shell(root: Path, errors: list[str]) -> int: count = 0 for path in iter_regular_files(root): if path.suffix.lower() != ".sh": continue result = subprocess.run( ["bash", "-n", str(path)], text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False, ) count += 1 if result.returncode != 0: message = result.stderr.strip() or result.stdout.strip() errors.append(f"Shell 语法失败 {path.relative_to(root).as_posix()}: {message}") return count def path_risk(path: Path) -> str | None: resolved = path.resolve(strict=False) for root in PERSONAL_OR_TEMP_ROOTS: if under(resolved, root): return str(root) return None def text_absolute_paths(root: Path) -> Iterator[tuple[str, str, str]]: for path in iter_regular_files(root): if ( path.suffix.lower() not in TEXT_PATH_SUFFIXES or path.stat().st_size > 4 * 1024 * 1024 ): continue data = path.read_bytes() if b"\x00" in data: continue try: text = data.decode("utf-8") except UnicodeDecodeError: continue source = path.relative_to(root).as_posix() for line_number, line in enumerate(text.splitlines(), 1): for match in ABSOLUTE_PATH_RE.finditer(line): if ( match.start() >= 1 and line[match.start() - 1] == ":" and match.group(1).startswith("//") ): continue value = match.group(1).rstrip(".,;:)]}'\"") if value != "/": yield source, f"line:{line_number}", value def normalize_allowed_roots(values: list[Path]) -> list[Path]: roots: list[Path] = [] for value in values: expanded = value.expanduser() if not expanded.is_absolute(): raise ValidationError(f"allowed-absolute-root 必须是绝对路径: {value}") roots.append(expanded.resolve(strict=False)) return roots def validate_absolute_paths( root: Path, payloads: dict[str, Any], allowed_roots: list[Path], formal: bool, errors: list[str], warnings: list[str], ) -> list[dict[str, object]]: entries: list[dict[str, object]] = [] normalized_allowed = normalize_allowed_roots(allowed_roots) references: list[tuple[str, str, str]] = [] for source, payload in payloads.items(): for location, value in iter_strings(payload): if value.startswith("/"): references.append((source, location, value)) references.extend(text_absolute_paths(root)) seen: set[tuple[str, str, str]] = set() for source, location, value in references: key = (source, location, value) if key in seen: continue seen.add(key) path = Path(value).resolve(strict=False) exists, access_error = probe_path_exists(path) allowed_by = next((item for item in normalized_allowed if under(path, item)), None) risk_root = None if allowed_by is not None else path_risk(path) build_input = source in BUILD_INPUT_JSONS entry = { "source": source, "location": location, "path": value, "resolved_path": str(path), "exists": exists, "access_error": access_error, "build_input": build_input, "allowed_by": str(allowed_by) if allowed_by else None, "personal_or_temporary_root": risk_root, } entries.append(entry) if access_error: message = ( f"外部绝对路径无法在当前主机检查 {source}:{location}: " f"{value}: {access_error}" ) (errors if build_input else warnings).append(message) elif not exists: message = f"外部绝对路径不存在 {source}:{location}: {value}" if build_input: errors.append(f"外部绝对路径不存在 {source}:{location}: {value}") else: warnings.append(message) if risk_root: message = f"绝对路径位于个人或临时目录 {source}:{location}: {value}" (errors if formal else warnings).append(message) return entries def load_allowed_overrides(psp_root: Path, errors: list[str]) -> set[tuple[str, str]]: path = psp_root / "metadata" / "allowed-overrides.json" if not path.exists(): return set() try: payload = load_json(path) except (OSError, json.JSONDecodeError) as exc: errors.append(f"allowed-overrides.json 解析失败: {exc}") return set() if not isinstance(payload, dict) or payload.get("schema_version") != 1: errors.append("metadata/allowed-overrides.json schema_version 必须为 1") return set() items = payload.get("overrides") if not isinstance(items, list): errors.append("metadata/allowed-overrides.json.overrides 必须是数组") return set() allowed: set[tuple[str, str]] = set() for index, item in enumerate(items): if not isinstance(item, dict): errors.append(f"allowed-overrides.json.overrides[{index}] 必须是对象") continue try: psp_path = normalize_relative(str(item.get("psp_path", "")), "psp_path") app_path = normalize_relative( str(item.get("application_path", "")), "application_path" ) except ValidationError as exc: errors.append(str(exc)) continue if item.get("winner") != "psp": errors.append(f"allowed-overrides.json.overrides[{index}].winner 必须为 psp") for field in ("reason", "confirmed_by", "confirmed_at"): if not isinstance(item.get(field), str) or not str(item.get(field)).strip(): errors.append(f"allowed-overrides.json.overrides[{index}].{field} 不能为空") allowed.add((app_path, psp_path)) return allowed def parse_override_map(value: str) -> tuple[Path, str]: if "=" not in value: raise argparse.ArgumentTypeError("override-map 必须为 APP_DIR=PSP_SUBDIR") app, psp_subdir = value.split("=", 1) try: relative = normalize_relative(psp_subdir, "PSP_SUBDIR") except ValidationError as exc: raise argparse.ArgumentTypeError(str(exc)) from exc return Path(app).expanduser(), relative def scan_overrides( psp_root: Path, mappings: list[tuple[Path, str]], allowed: set[tuple[str, str]], formal: bool, errors: list[str], warnings: list[str], ) -> list[dict[str, object]]: conflicts: list[dict[str, object]] = [] for app_dir_raw, psp_subdir in mappings: app_dir = app_dir_raw.resolve(strict=False) psp_dir = psp_root / psp_subdir if not app_dir.is_dir(): errors.append(f"应用配置目录不存在: {app_dir}") continue if not psp_dir.is_dir(): errors.append(f"PSP 配置目录不存在: {psp_dir}") continue for app_path in iter_regular_files(app_dir): relative = app_path.relative_to(app_dir) psp_path = psp_dir / relative if not psp_path.is_file(): continue app_relative = app_path.as_posix() if app_dir_raw.is_absolute(): app_key = f"{app_dir_raw.name}/{relative.as_posix()}" else: app_key = (app_dir_raw / relative).as_posix() psp_key = (PurePosixPath(psp_subdir) / relative.as_posix()).as_posix() is_allowed = (app_key, psp_key) in allowed conflict = { "application_path": app_relative, "application_key": app_key, "application_sha256": sha256_file(app_path), "psp_path": psp_path.as_posix(), "psp_key": psp_key, "psp_sha256": sha256_file(psp_path), "winner": "psp", "allowed": is_allowed, } conflicts.append(conflict) if not is_allowed: message = f"未确认的 PSP 覆盖: {app_key} -> {psp_key}" (errors if formal else warnings).append(message) return conflicts def build_bsp_lock(dependency: dict[str, object], warnings: list[str]) -> dict[str, object]: permissions = dependency.get("output_permissions") permissions = permissions if isinstance(permissions, dict) else {} live_check = permissions.get("current_check") live_check = live_check if isinstance(live_check, dict) else {} return { "schema_version": 1, "handoff_path": dependency.get("manifest_path"), "handoff_sha256": dependency.get("manifest_sha256"), "bsp_project": dependency.get("project"), "bsp_commit": dependency.get("bsp_commit"), "bsp_source_dirty": dependency.get("bsp_source_dirty"), "chip": dependency.get("chip"), "sdk": dependency.get("sdk"), "build_type": dependency.get("build_type"), "system_form": dependency.get("system_form"), "build_id": dependency.get("build_id"), "imgs_version_path": dependency.get("imgs_version_path"), "artifacts": dependency.get("artifacts", []), "image_partition_margin": dependency.get("image_partition_margin"), "output_permissions": { "policy": permissions.get("policy"), "evidence_path": dependency.get("output_permissions_path"), "output_root": permissions.get("output_root"), "imgs_version_path": permissions.get("imgs_version_path"), "passed": live_check.get("passed") is True, }, "mixed_build": None, "warnings": warnings, } def mixed_artifact_map(psp_root: Path, errors: list[str]) -> dict[str, str]: path = psp_root / "metadata" / "bsp.lock.json" if not path.is_file(): return {} try: payload = load_json(path) except (OSError, json.JSONDecodeError) as exc: errors.append(f"metadata/bsp.lock.json 解析失败: {exc}") return {} if not isinstance(payload, dict) or payload.get("schema_version") != 1: errors.append("metadata/bsp.lock.json schema_version 必须为 1") return {} mixed = payload.get("mixed_build") if mixed is None: return {} if not isinstance(mixed, dict) or not isinstance(mixed.get("artifacts"), list): errors.append("metadata/bsp.lock.json.mixed_build.artifacts 必须是数组") return {} result: dict[str, str] = {} for index, item in enumerate(mixed["artifacts"]): if not isinstance(item, dict): errors.append(f"mixed_build.artifacts[{index}] 必须是对象") continue absolute = item.get("absolute_path") digest = item.get("sha256") required = ("build_id", "bsp_commit", "reason", "confirmed_by", "confirmed_at") if not isinstance(absolute, str) or not Path(absolute).is_absolute(): errors.append(f"mixed_build.artifacts[{index}].absolute_path 非法") continue if not isinstance(digest, str) or len(digest) != 64: errors.append(f"mixed_build.artifacts[{index}].sha256 非法") continue if any(not isinstance(item.get(field), str) or not item.get(field) for field in required): errors.append(f"mixed_build.artifacts[{index}] 缺少来源/确认字段") continue result[str(Path(absolute).resolve(strict=False))] = digest return result def validate_bsp_sources( psp_root: Path, psp_json: dict[str, Any], dependency: dict[str, object] | None, errors: list[str], ) -> list[dict[str, object]]: copied = psp_json.get("EmxResToProduct") if not isinstance(copied, list): return [] primary: dict[str, str] = {} if dependency: for item in dependency.get("artifacts", []): if isinstance(item, dict) and isinstance(item.get("absolute_path"), str): primary[str(Path(item["absolute_path"]).resolve(strict=False))] = str( item.get("sha256", "") ) mixed = mixed_artifact_map(psp_root, errors) results: list[dict[str, object]] = [] for index, item in enumerate(copied): if not isinstance(item, dict) or not isinstance(item.get("src"), str): continue value = item["src"] if not value.startswith("/"): continue path = Path(value).resolve(strict=False) source = "primary" if str(path) in primary else "mixed" if str(path) in mixed else None expected_hash = primary.get(str(path)) or mixed.get(str(path)) actual_hash = sha256_file(path) if path.is_file() and not path.is_symlink() else None if source is None: errors.append( f"psp.json.EmxResToProduct[{index}].src 未被 BSP handoff 或 mixed_build 锁定: {value}" ) elif actual_hash != expected_hash: errors.append(f"psp.json.EmxResToProduct[{index}].src hash 与 BSP 锁定不一致") results.append( { "index": index, "path": str(path), "source": source, "expected_sha256": expected_hash, "actual_sha256": actual_hash, } ) return results def find_key_values(payloads: dict[str, Any], normalized_key: str) -> list[dict[str, str]]: found: list[dict[str, str]] = [] def visit(source: str, value: Any, location: str) -> None: if isinstance(value, dict): for key, child in value.items(): child_location = f"{location}.{key}" if key.casefold().replace("_", "") == normalized_key and isinstance(child, str): found.append({"source": source, "location": child_location, "value": child}) visit(source, child, child_location) elif isinstance(value, list): for index, child in enumerate(value): visit(source, child, f"{location}[{index}]") for source, payload in payloads.items(): visit(source, payload, "$") return found def validate_psp( psp_root: Path, project: str | None, expected_sdk: str | None, expected_media: str | None, expected_ai_plugin: str | None, bsp_handoff_path: Path | None, bsp_lock_output: Path | None, expected_bsp_project: str | None, expected_chip: str | None, expected_bsp_sdk: str | None, expected_build_type: str | None, expected_system_form: str | None, allowed_absolute_roots: list[Path], override_maps: list[tuple[Path, str]], formal: bool, sensor_enum_header: Path | None = None, expected_sensors: list[tuple[str, str]] | None = None, ) -> dict[str, object]: errors: list[str] = [] warnings: list[str] = [] psp_root = psp_root.expanduser().resolve(strict=True) if not psp_root.is_dir() or psp_root.is_symlink(): raise ValidationError(f"PSP 根目录必须是普通目录: {psp_root}") psp_json_path = psp_root / "psp.json" if not psp_json_path.is_file() or psp_json_path.is_symlink(): raise ValidationError(f"缺少普通文件 psp.json: {psp_json_path}") payloads = collect_json(psp_root, errors) psp_json = payloads.get("psp.json") if not isinstance(psp_json, dict): errors.append("psp.json 根节点必须是对象") psp_json = {} ini_count = validate_ini(psp_root, errors) shell_count = validate_shell(psp_root, errors) paths = validate_absolute_paths( psp_root, payloads, allowed_absolute_roots, formal, errors, warnings ) required_identity = { "project": project, "expected_bsp_project": expected_bsp_project, "expected_chip": expected_chip, "expected_bsp_sdk": expected_bsp_sdk, "expected_build_type": expected_build_type, "expected_system_form": expected_system_form, } for field, value in required_identity.items(): if not isinstance(value, str) or not value.strip(): errors.append(f"缺少候选 PSP 必需身份字段: {field}") version_checks: dict[str, object] = {} checks = ( ("emxsdkversion", expected_sdk, "SDK"), ("emxmediaversion", expected_media, "Media"), ("emxaipluginversion", expected_ai_plugin, "AIPlugin"), ) for key, expected, label in checks: values = find_key_values(payloads, key) version_checks[label] = {"expected": expected, "found": values} if expected is not None: if not values: errors.append(f"未找到可核对的 {label} 版本字段") for item in values: if item["value"] != expected: errors.append( f"{label} 版本不匹配 {item['source']}:{item['location']}: " f"期望 {expected!r},实际 {item['value']!r}" ) dependency: dict[str, object] | None = None if bsp_handoff_path is not None: bsp_result = validate_bsp_handoff.validate_handoff( bsp_handoff_path, expected_project=expected_bsp_project, expected_chip=expected_chip, expected_sdk=expected_bsp_sdk, expected_build_type=expected_build_type, expected_system_form=expected_system_form, repair_output_permissions=True, ) if bsp_result.get("passed") is not True: errors.extend(f"BSP handoff: {item}" for item in bsp_result.get("errors", [])) warnings.extend(f"BSP handoff: {item}" for item in bsp_result.get("warnings", [])) raw_dependency = bsp_result.get("dependency") if isinstance(raw_dependency, dict): dependency = raw_dependency if dependency and bsp_lock_output is not None and bsp_result.get("passed") is True: output = bsp_lock_output.expanduser().resolve(strict=False) if output != psp_root / "metadata" / "bsp.lock.json": raise ValidationError("bsp-lock-output 必须精确为 PSP/metadata/bsp.lock.json") write_json_atomic(output, build_bsp_lock(dependency, list(bsp_result["warnings"]))) elif bsp_lock_output is not None: raise ValidationError("写 bsp.lock.json 时必须同时提供 --bsp-handoff") else: errors.append("未提供 BSP handoff;不能形成可交付 candidate") bsp_sources = validate_bsp_sources(psp_root, psp_json, dependency, errors) sensor_baseline = validate_sensor_baseline( psp_root, expected_chip, sensor_enum_header, expected_sensors or [], errors, ) allowed = load_allowed_overrides(psp_root, errors) conflicts = scan_overrides( psp_root, override_maps, allowed, formal, errors, warnings ) result: dict[str, object] = { "schema_version": 1, "passed": not errors, "mode": "formal" if formal else "local", "project": project, "psp_root": str(psp_root), "checks": { "json_files": len(payloads), "ini_files": ini_count, "shell_files": shell_count, "absolute_paths": paths, "versions": version_checks, "bsp_dependency": dependency, "bsp_sources": bsp_sources, "sensor_baseline": sensor_baseline, "configuration_overrides": conflicts, }, "errors": errors, "warnings": warnings, } return result def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("psp_root", type=Path) parser.add_argument("--project", required=True) parser.add_argument("--expected-sdk") parser.add_argument("--expected-media-version") parser.add_argument("--expected-ai-plugin-version") parser.add_argument("--bsp-handoff", type=Path, required=True) parser.add_argument("--bsp-lock-output", type=Path) parser.add_argument("--expected-bsp-project", required=True) parser.add_argument("--expected-chip", required=True) parser.add_argument("--expected-bsp-sdk", required=True) parser.add_argument("--expected-build-type", required=True) parser.add_argument( "--expected-system-form", required=True, choices=("single-os", "dual-os") ) parser.add_argument( "--sensor-enum-header", type=Path, help=( "可选覆盖:CV181X 的 sensor_list.h 或 CV184X 的 sensor_cfg.h;" "默认按芯片使用 skill 内置基线" ), ) parser.add_argument( "--expected-sensor", action="append", default=[], type=parse_expected_sensor, metavar="INI_SECTION=ENUM_NAME", help="规格书选定的 Sensor 枚举名称;每个 [sensor*] section 重复一次", ) parser.add_argument( "--allowed-absolute-root", action="append", default=[], type=Path, help="显式批准的团队共享或当前验证根;可重复", ) parser.add_argument( "--override-map", action="append", default=[], type=parse_override_map, metavar="APP_DIR=PSP_SUBDIR", ) parser.add_argument("--formal", action="store_true") parser.add_argument("--output", type=Path) return parser def main() -> int: args = build_parser().parse_args() try: result = validate_psp( args.psp_root, args.project, args.expected_sdk, args.expected_media_version, args.expected_ai_plugin_version, args.bsp_handoff, args.bsp_lock_output, args.expected_bsp_project, args.expected_chip, args.expected_bsp_sdk, args.expected_build_type, args.expected_system_form, args.allowed_absolute_root, args.override_map, args.formal, args.sensor_enum_header, args.expected_sensor, ) if args.output is not None: write_json_atomic(args.output, result) except (ValidationError, OSError, json.JSONDecodeError) as exc: result = {"schema_version": 1, "passed": False, "errors": [str(exc)], "warnings": []} print(json.dumps(result, ensure_ascii=False, indent=2)) return 0 if result.get("passed") is True else 2 if __name__ == "__main__": raise SystemExit(main())