aboutsummaryrefslogtreecommitdiff
path: root/tools/check_resources.py
diff options
context:
space:
mode:
authorcirakg <cirakg@cirakg.xyz>2026-10-20 18:36:54 +0200
committercirakg <cirakg@cirakg.xyz>2026-10-20 18:36:54 +0200
commitf466977249d9ac10fe8b75180c30cb90dbcc22ca (patch)
treef346500d39d077aaaeb350e0e99647a4d6e6367c /tools/check_resources.py
parent479d7ba2e843d5ea93979f2eed68f4d38d146c25 (diff)
build(lint): validate resource XML against the SDK schema and cross-check the settings form
tools/check_resources.py checks every resource file against the SDK's resources.xsd and that settings, properties and strings agree. Adds the pure-Python, MIT-licensed xmlschema to the dev image. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'tools/check_resources.py')
-rw-r--r--tools/check_resources.py125
1 files changed, 125 insertions, 0 deletions
diff --git a/tools/check_resources.py b/tools/check_resources.py
new file mode 100644
index 0000000..cdde8df
--- /dev/null
+++ b/tools/check_resources.py
@@ -0,0 +1,125 @@
+#!/usr/bin/env python3
+# Author: cirakg & claude ai
+# Summary: Validate the resource XML against the SDK's resources.xsd and cross-check settings, properties and strings.
+"""Validate the app's resource XML and cross-check the settings form (M7).
+
+Checks:
+1. Every XML file under resources/ and resources-*/ validates against the
+ Connect IQ SDK's bin/resources.xsd (the schema monkeyc ships).
+2. settings.xml refers only to properties defined in properties.xml and to
+ strings defined in strings.xml (titles, prompts, descriptions, list
+ entries, error messages).
+3. Every property appears in the form exactly once: a property the phone
+ can't show can't be cleared or read back, and one shown twice is a typo.
+4. A list setting's default value is one of its entries, and read-only
+ (status) settings are string properties.
+
+The watch side of the contract (every key the code reads exists, the form
+defaults match) is checked by the Monkey C test settingsDefaultsMatchProperties.
+
+Usage: check_resources.py ROOT [--xsd PATH]
+ (default PATH: $CIQ_SDK/bin/resources.xsd)
+Exit: 0 all good, 1 problems found, 2 the schema can't be read.
+"""
+
+from __future__ import annotations
+
+import argparse
+import os
+import re
+import sys
+import xml.etree.ElementTree as ET
+from collections import Counter
+from pathlib import Path
+
+import xmlschema
+
+EXIT_OK, EXIT_PROBLEMS, EXIT_NO_SCHEMA = 0, 1, 2
+
+REFERENCE_RE = re.compile(r"@(Strings|Properties)\.(\w+)")
+
+
+def resource_files(root: Path) -> list[Path]:
+ """Every resource XML file, in a stable order."""
+ files: list[Path] = []
+ for folder in sorted(root.glob("resources*")):
+ if folder.is_dir():
+ files.extend(sorted(folder.rglob("*.xml")))
+ return files
+
+
+def validate(schema: xmlschema.XMLSchema, files: list[Path], root: Path) -> list[str]:
+ """Schema errors, one line per problem."""
+ problems = []
+ for path in files:
+ for error in schema.iter_errors(str(path)):
+ problems.append(f"{path.relative_to(root)}: {error.reason} (at {error.path})")
+ return problems
+
+
+def ids(path: Path, tag: str) -> list[str]:
+ """The id attributes of the given elements in one file."""
+ return [element.get("id", "") for element in ET.parse(path).getroot().iter(tag)]
+
+
+def cross_check(settings: Path, properties: Path, strings: list[Path]) -> list[str]:
+ """Problems between settings.xml, properties.xml and the strings."""
+ problems: list[str] = []
+ prop_types = {p.get("id", ""): p.get("type", "") for p in ET.parse(properties).getroot().iter("property")}
+ prop_defaults = {p.get("id", ""): (p.text or "").strip() for p in ET.parse(properties).getroot().iter("property")}
+ string_ids = {sid for path in strings for sid in ids(path, "string")}
+ text = settings.read_text(encoding="utf-8")
+ for kind, name in REFERENCE_RE.findall(text):
+ if kind == "Strings" and name not in string_ids:
+ problems.append(f"settings.xml: @Strings.{name} is not defined")
+ if kind == "Properties" and name not in prop_types:
+ problems.append(f"settings.xml: @Properties.{name} is not defined in properties.xml")
+ used: Counter[str] = Counter()
+ for setting in ET.parse(settings).getroot().iter("setting"):
+ key = REFERENCE_RE.sub(r"\2", setting.get("propertyKey", ""))
+ used[key] += 1
+ config = setting.find("settingConfig")
+ if config is None or key not in prop_types:
+ continue
+ if config.get("type") == "list":
+ values = [entry.get("value", "") for entry in config.iter("listEntry")]
+ if prop_defaults[key] not in values:
+ problems.append(f"{key}: default {prop_defaults[key]!r} is not one of the list values {values}")
+ if config.get("readonly") == "true" and prop_types[key] != "string":
+ problems.append(f"{key}: read-only settings must be string properties, not {prop_types[key]}")
+ for key in prop_types:
+ if used[key] == 0:
+ problems.append(f"properties.xml: {key} is not in the settings form")
+ elif used[key] > 1:
+ problems.append(f"settings.xml: {key} appears {used[key]} times")
+ return problems
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(description="Validate resource XML and cross-check the settings form.")
+ parser.add_argument("root", type=Path, help="the project folder")
+ parser.add_argument("--xsd", type=Path, help="the SDK's resources.xsd")
+ args = parser.parse_args(argv)
+ root: Path = args.root
+ xsd: Path | None = args.xsd
+ if xsd is None and os.environ.get("CIQ_SDK"):
+ xsd = Path(os.environ["CIQ_SDK"]) / "bin" / "resources.xsd"
+ if xsd is None or not xsd.is_file():
+ print(f"error: SDK schema not found ({xsd}); set CIQ_SDK or pass --xsd", file=sys.stderr)
+ return EXIT_NO_SCHEMA
+ schema = xmlschema.XMLSchema(str(xsd))
+ files = resource_files(root)
+ problems = validate(schema, files, root)
+ problems += cross_check(
+ root / "resources" / "settings" / "settings.xml",
+ root / "resources" / "settings" / "properties.xml",
+ [path for path in files if path.parent.name == "strings"],
+ )
+ for problem in problems:
+ print(problem)
+ print(f"check_resources: {len(files)} files, {len(problems)} problems")
+ return EXIT_PROBLEMS if problems else EXIT_OK
+
+
+if __name__ == "__main__":
+ sys.exit(main())