| #!/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()) |