blob: 0c79469c3ebbd8459a0f21c83b47040f061a2499 [file]
#!/usr/bin/env python3
#
# Copyright (c) 2026 Project CHIP Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Validates that XML files in the ZAP data model directory were generated by
Alchemy (https://github.com/project-chip/alchemy) and contain a complete,
well-formed metadata block.
Expected comment block (immediately after the license header):
<!--
XML generated by Alchemy; DO NOT EDIT.
Source: src/app_clusters/Foo.adoc
Parameters: zap <flags> ...
Git: 1.0-fall2025-42-gABCDEF1
Alchemy: v1.5.41
-->
Required fields
---------------
Alchemy – tool version (vX.Y.Z)
Git – spec repo git-describe string produced by ``git describe``.
When HEAD is not at a tag (i.e. the value contains a
``-<N>-g`` distance component), a commit SHA suffix
(g<7+ hex digits>, e.g. g98372b049) is required.
Clean tags (HEAD exactly at a tag, no distance component)
are accepted without a SHA suffix.
Source – one or more source .adoc paths
Parameters – command-line arguments passed to Alchemy
Allowlists
----------
MANUAL_ALLOWLIST
Files that pre-date Alchemy adoption and are intentionally maintained
by hand. Do NOT add new entries here; edit alchemy_allowlists.yaml in
src/app/zap-templates/zcl/data-model/chip/ instead.
INCOMPLETE_METADATA_ALLOWLIST
Alchemy-generated files that pre-date the full metadata header (older
Alchemy versions omitted the "Alchemy: v..." line or left "Git:" empty).
These must be regenerated with a current Alchemy version before they
can be removed from this list. Do NOT add new entries here; edit
alchemy_allowlists.yaml in src/app/zap-templates/zcl/data-model/chip/
instead.
"""
import argparse
import re
import subprocess
import sys
from pathlib import Path
import yaml
# ---------------------------------------------------------------------------
# Allowlists – loaded from
# src/app/zap-templates/zcl/data-model/chip/alchemy_allowlists.yaml.
#
# MANUAL_ALLOWLIST: hand-maintained XML files that pre-date Alchemy.
# INCOMPLETE_METADATA_ALLOWLIST: Alchemy-generated files with incomplete
# metadata headers (older Alchemy releases).
#
# Edit alchemy_allowlists.yaml to update entries; do NOT add new ones.
# ---------------------------------------------------------------------------
_ALLOWLISTS_PATH = Path(__file__).resolve().parents[3] / "src/app/zap-templates/zcl/data-model/chip/alchemy_allowlists.yaml"
def _load_allowlists() -> tuple:
"""Load MANUAL_ALLOWLIST and INCOMPLETE_METADATA_ALLOWLIST from YAML."""
with open(_ALLOWLISTS_PATH, encoding="utf-8") as f:
data = yaml.safe_load(f)
return set(data["MANUAL_ALLOWLIST"]), set(data["INCOMPLETE_METADATA_ALLOWLIST"])
MANUAL_ALLOWLIST: set[str]
INCOMPLETE_METADATA_ALLOWLIST: set[str]
MANUAL_ALLOWLIST, INCOMPLETE_METADATA_ALLOWLIST = _load_allowlists()
# ---------------------------------------------------------------------------
# Compiled patterns for the Alchemy metadata comment block
# ---------------------------------------------------------------------------
_COMMENT_RE = re.compile(r'<!--(.*?)-->', re.DOTALL)
_MARKER_RE = re.compile(r'XML generated by Alchemy; DO NOT EDIT\.')
# Alchemy tool version, e.g. "Alchemy: v1.5.41"
_ALCHEMY_VER_RE = re.compile(r'^Alchemy:\s+(v\d+\.\d+\.\d+\S*)\s*$', re.MULTILINE)
# Spec repo git-describe, e.g. "Git: 0.9-fall2025-401-g98372b049"
# Allow trailing whitespace; value must be non-empty.
_SPEC_VER_RE = re.compile(r'^Git:\s+(\S.*?)\s*$', re.MULTILINE)
# Commit SHA embedded in git-describe as g<7+ hex chars>
# The (?:^|-) prefix anchors to start-of-string or the "-" separator in
# git-describe output, avoiding false matches mid-word.
_SHA_RE = re.compile(r'(?:^|-)g([0-9a-f]{7,})', re.IGNORECASE)
# Detects the "<N>-g" distance+SHA portion of git-describe output.
# When present, the value is not a clean tag and a SHA must follow.
# When absent, the value is a clean tag (HEAD exactly at a tag) and
# no SHA suffix is appended by git describe.
_HAS_DISTANCE_RE = re.compile(r'-\d+-g', re.IGNORECASE)
# Source .adoc path(s), may be space-separated list
_SOURCE_RE = re.compile(r'^Source:\s+(\S.*?)\s*$', re.MULTILINE)
# Alchemy command-line parameters
_PARAMS_RE = re.compile(r'^Parameters:\s+(\S.*?)\s*$', re.MULTILINE)
# Reject "-dirty" or "+dirty" suffixes indicating uncommitted changes.
_DIRTY_RE = re.compile(r'[-+]dirty\b', re.IGNORECASE)
# Guard: the two allowlists must never overlap.
_OVERLAP = MANUAL_ALLOWLIST & INCOMPLETE_METADATA_ALLOWLIST
if _OVERLAP:
raise RuntimeError(
f"MANUAL_ALLOWLIST and INCOMPLETE_METADATA_ALLOWLIST overlap: {sorted(_OVERLAP)}"
)
def _find_alchemy_comment(content: str) -> str | None:
"""Return the Alchemy metadata comment block, or None if absent."""
for m in _COMMENT_RE.finditer(content):
if _MARKER_RE.search(m.group(1)):
return m.group(1)
return None
def _get_base_content(git_ref: str, repo_path: str, root: Path) -> str | None:
"""Retrieve file content at *git_ref* via ``git show``, or None."""
try:
result = subprocess.run(
["git", "show", f"{git_ref}:{repo_path}"],
capture_output=True, text=True, check=True,
cwd=root,
)
return result.stdout
except (subprocess.CalledProcessError, FileNotFoundError):
return None
def _check_hand_edits(
xml_files: list[Path], root: Path, diff_base: str,
) -> list[str]:
"""Flag Alchemy-generated XML files whose content changed but whose
metadata comment is identical to *diff_base*, indicating a hand-edit."""
# Build a mapping from repo-relative path string → absolute Path so we can
# match git diff output (which uses repo-relative paths) precisely, even
# when multiple directories contain files with the same basename.
# Pass paths relative to root so git diff matches tracked repo paths
# correctly, regardless of whether --root was supplied as an absolute path.
rel_to_abs: dict[str, Path] = {}
for f in xml_files:
try:
rel_to_abs[str(f.relative_to(root))] = f
except ValueError:
rel_to_abs[str(f)] = f
try:
result = subprocess.run(
["git", "diff", "--name-only", diff_base, "--"] + list(rel_to_abs),
capture_output=True, text=True, check=True,
cwd=root,
)
except (subprocess.CalledProcessError, FileNotFoundError) as exc:
return [f"unable to run git diff: {exc}"]
changed_rel_paths = {p for p in result.stdout.splitlines() if p.strip()}
if not changed_rel_paths:
return []
errors: list[str] = []
for rel_str, xml_file in rel_to_abs.items():
if rel_str not in changed_rel_paths:
continue
# Skip files that are expected to be hand-maintained.
if xml_file.name in MANUAL_ALLOWLIST:
continue
try:
new_content = xml_file.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
continue
new_comment = _find_alchemy_comment(new_content)
if new_comment is None:
continue # No Alchemy block — other checks handle this.
rel = Path(rel_str)
old_content = _get_base_content(diff_base, rel_str, root)
if old_content is None:
continue # New file — no base to compare against.
old_comment = _find_alchemy_comment(old_content)
if old_comment is None:
continue # Was manual before, now Alchemy — that is fine.
if new_comment.strip() == old_comment.strip():
errors.append(
f"{rel}: XML content changed but Alchemy metadata is "
"identical to the base branch — suspected hand-edit.\n"
" Regenerate this file with Alchemy instead of editing "
"it manually."
)
return errors
def validate_file(filepath: Path) -> list[str]:
"""
Validate a single XML file.
Returns a (possibly empty) list of human-readable error strings.
An empty list means the file passed all applicable checks.
"""
filename = filepath.name
try:
content = filepath.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError) as exc:
return [f"cannot read file: {exc}"]
comment = _find_alchemy_comment(content)
# ── Tier 1: must have the Alchemy marker ────────────────────────────────
if comment is None:
if filename in MANUAL_ALLOWLIST:
return [] # Grandfathered manually-maintained file
return [
"missing 'XML generated by Alchemy; DO NOT EDIT.' marker\n"
" File is not in the manual-maintenance allowlist.\n"
" New data-model XML files must be generated using Alchemy.\n"
" See https://github.com/project-chip/alchemy"
]
# ── Stale allowlist: MANUAL file now has the Alchemy marker ─────────────
if filename in MANUAL_ALLOWLIST:
return [
"file is on MANUAL_ALLOWLIST but contains the Alchemy marker\n"
" Remove it from MANUAL_ALLOWLIST in "
"src/app/zap-templates/zcl/data-model/chip/alchemy_allowlists.yaml."
]
# ── Tier 2: metadata field completeness ─────────────────────────────────
if filename in INCOMPLETE_METADATA_ALLOWLIST:
return [] # Known legacy file – skip field-level checks
errors: list[str] = []
alchemy_match = _ALCHEMY_VER_RE.search(comment)
if not alchemy_match:
errors.append(
"missing or malformed 'Alchemy: vX.Y.Z' version line\n"
" Regenerate the file with a current Alchemy release."
)
elif _DIRTY_RE.search(alchemy_match.group(1)):
errors.append(
f"Alchemy version '{alchemy_match.group(1)}' has a dirty suffix\n"
" Regenerate with an official (clean) Alchemy release."
)
spec_match = _SPEC_VER_RE.search(comment)
if not spec_match:
errors.append(
"missing 'Git: <spec-version>' line\n"
" Regenerate the file so the spec repo revision is recorded."
)
else:
git_value = spec_match.group(1)
if not _SHA_RE.search(git_value) and _HAS_DISTANCE_RE.search(git_value):
errors.append(
f"'Git:' value '{git_value}' has a distance component but no commit SHA\n"
" Expected git-describe format for non-tag commits: X.Y-<tag>-<N>-g<SHA>\n"
" e.g. 0.9-fall2025-401-g98372b049\n"
" Clean tags (no distance component) are accepted without a SHA."
)
if _DIRTY_RE.search(git_value):
errors.append(
f"'Git:' value '{git_value}' has a dirty suffix\n"
" The spec repo must not have uncommitted changes when\n"
" generating XML files."
)
if not _SOURCE_RE.search(comment):
errors.append("missing 'Source: <path>' line")
if not _PARAMS_RE.search(comment):
errors.append("missing 'Parameters: <args>' line")
return errors
def main() -> int:
parser = argparse.ArgumentParser(
description=(
"Verify that ZAP XML data-model files carry a valid Alchemy "
"metadata block (Alchemy version, spec Git version with SHA, "
"source path, and generation parameters)."
)
)
parser.add_argument(
"paths",
nargs="*",
default=["src/app/zap-templates/zcl/data-model/chip"],
help=(
"Directories or individual XML files to check. "
"Directories are scanned non-recursively for *.xml. "
"(default: src/app/zap-templates/zcl/data-model/chip)"
),
)
parser.add_argument(
"--root",
default=".",
metavar="DIR",
help="Repository root used when paths are relative (default: .)",
)
parser.add_argument(
"--check-stale-allowlists",
action="store_true",
help=(
"Additionally check for stale allowlist entries (exits 1 on "
"any finding): files that no longer exist on disk, "
"INCOMPLETE_METADATA_ALLOWLIST entries whose metadata is now "
"complete, and MANUAL_ALLOWLIST entries that now carry the "
"Alchemy marker."
),
)
parser.add_argument(
"--diff-base",
metavar="GIT_REF",
default=None,
help=(
"Git ref (branch, tag, or SHA) to compare against. When set, "
"any Alchemy-generated XML file whose content changed but whose "
"metadata comment is identical to the base is flagged as a "
"suspected hand-edit."
),
)
args = parser.parse_args()
root = Path(args.root)
xml_files: list[Path] = []
for path_arg in args.paths:
p = root / path_arg
if p.is_dir():
xml_files.extend(sorted(p.glob("*.xml")))
elif p.is_file():
xml_files.append(p)
else:
print(f"warning: path not found: {p}", file=sys.stderr)
xml_files = list(dict.fromkeys(xml_files))
if not xml_files:
print("ERROR: no XML files found to check.", file=sys.stderr)
return 2
# ── Stale-allowlist checks (optional) ────────────────────────────────────
stale_warnings: list[str] = []
if args.check_stale_allowlists:
seen_filenames = {f.name for f in xml_files}
for name in sorted(MANUAL_ALLOWLIST - seen_filenames):
stale_warnings.append(
f"MANUAL_ALLOWLIST entry '{name}' not found on disk"
)
for name in sorted(INCOMPLETE_METADATA_ALLOWLIST - seen_filenames):
stale_warnings.append(
f"INCOMPLETE_METADATA_ALLOWLIST entry '{name}' not found on disk"
)
# Check if INCOMPLETE_METADATA_ALLOWLIST files now have full metadata.
for xml_file in xml_files:
fname = xml_file.name
if fname not in INCOMPLETE_METADATA_ALLOWLIST:
continue
try:
content = xml_file.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
continue
comment = _find_alchemy_comment(content)
if comment is None:
continue
has_alchemy = bool(_ALCHEMY_VER_RE.search(comment))
spec_match = _SPEC_VER_RE.search(comment)
# Mirror validate_file(): a SHA is only required when the
# git-describe value has a distance component (-N-g<SHA>).
# Clean tags (HEAD exactly at a tag) have no SHA suffix and
# are considered valid without one.
if spec_match:
git_value = spec_match.group(1)
sha_ok = not _HAS_DISTANCE_RE.search(git_value) or bool(_SHA_RE.search(git_value))
else:
sha_ok = False
has_source = bool(_SOURCE_RE.search(comment))
has_params = bool(_PARAMS_RE.search(comment))
if has_alchemy and sha_ok and has_source and has_params:
stale_warnings.append(
f"INCOMPLETE_METADATA_ALLOWLIST entry '{fname}' now has "
"complete metadata — remove it from the allowlist"
)
# ── Hand-edit detection (optional) ─────────────────────────────────────
hand_edit_errors: list[str] = []
if args.diff_base:
hand_edit_errors = _check_hand_edits(xml_files, root, args.diff_base)
# ── Primary validation ──────────────────────────────────────────────────
failures: dict[Path, list[str]] = {}
n_manual = 0
n_incomplete = 0
for xml_file in xml_files:
fname = xml_file.name
errs = validate_file(xml_file)
if errs:
failures[xml_file] = errs
elif fname in MANUAL_ALLOWLIST:
n_manual += 1
elif fname in INCOMPLETE_METADATA_ALLOWLIST:
n_incomplete += 1
if stale_warnings:
print("ERROR: stale allowlist entries detected:\n")
for w in stale_warnings:
print(f" {w}")
print()
if hand_edit_errors:
print("ERROR: suspected hand-edits to Alchemy-generated XML files:\n")
for msg in hand_edit_errors:
print(f" {msg}")
print(
"\nAlchemy-generated files must not be edited by hand. Re-run "
"Alchemy to regenerate them.\n"
)
if not failures:
n_full = len(xml_files) - n_manual - n_incomplete
summary = (
f"{len(xml_files)} XML file(s) passed Alchemy metadata validation "
f"({n_full} fully validated, {n_manual} manual-allowlist, "
f"{n_incomplete} incomplete-allowlist)."
)
if hand_edit_errors:
# Metadata validation passed but hand-edits were detected
print(f"NOTE: {summary}")
return 1
if stale_warnings:
print(f"NOTE: {summary}")
return 1
print(f"OK: all {summary}")
return 0
print("ERROR: the following XML file(s) failed Alchemy metadata validation:\n")
for filepath in sorted(failures):
try:
rel = filepath.relative_to(root)
except ValueError:
rel = filepath
print(f" {rel}:")
for err in failures[filepath]:
for line in err.splitlines():
print(f" {line}")
print()
print(
"All new ZAP XML data-model files must be generated using Alchemy.\n"
"See https://github.com/project-chip/alchemy for usage instructions.\n"
"\n"
"If a file genuinely needs to be maintained by hand, add it to\n"
"MANUAL_ALLOWLIST in "
"src/app/zap-templates/zcl/data-model/chip/alchemy_allowlists.yaml.\n"
"\n"
"If the file was generated by an older Alchemy release that did not\n"
"emit all metadata fields, add it to INCOMPLETE_METADATA_ALLOWLIST in\n"
"src/app/zap-templates/zcl/data-model/chip/alchemy_allowlists.yaml\n"
"and regenerate it with a current Alchemy release at the next\n"
"opportunity."
)
return 1
if __name__ == "__main__":
sys.exit(main())