Files
firmware-lowlevel-development/skills/firmware-lowlevel-psp-development/scripts/psp/validate_psp.py
T

874 lines
33 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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"(?<![A-Za-z0-9_.-])(/[A-Za-z0-9_@%+=:,./-]+)")
BUILD_INPUT_JSONS = {"psp.json", "metadata/bsp.lock.json"}
SENSOR_SECTION_RE = re.compile(r"^sensor(?:\d+)?$", re.IGNORECASE)
C_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
SKILL_ROOT = Path(__file__).resolve().parents[2]
BUNDLED_SENSOR_HEADERS = {
"cv181x": SKILL_ROOT / "references" / "sensor-enums" / "cv181x" / "sensor_list.h",
"cv184x": SKILL_ROOT / "references" / "sensor-enums" / "cv184x" / "sensor_cfg.h",
}
class ValidationError(RuntimeError):
"""The requested validation cannot be performed safely."""
def sha256_file(path: Path) -> 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 collect_json(root: Path, errors: list[str]) -> dict[str, Any]:
payloads: dict[str, Any] = {}
for path in sorted(root.rglob("*.json")):
if ".git" in path.parts:
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 sorted(root.rglob("*.ini")):
if ".git" in path.parts:
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 = sorted(
path
for path in psp_root.rglob("sensor_cfg.ini")
if ".git" not in path.parts and path.is_file() and not path.is_symlink()
)
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 sorted(root.rglob("*.sh")):
if ".git" in path.parts:
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 sorted(root.rglob("*")):
if (
not path.is_file()
or path.is_symlink()
or ".git" in path.parts
or 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 = path.exists()
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,
"build_input": build_input,
"allowed_by": str(allowed_by) if allowed_by else None,
"personal_or_temporary_root": risk_root,
}
entries.append(entry)
if 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 sorted(path for path in app_dir.rglob("*") if path.is_file()):
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]:
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"),
"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,
)
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())