From 850af9cd24a882e33c046454325dfb33891060df Mon Sep 17 00:00:00 2001 From: Peter Feerick <5500713+pfeerick@users.noreply.github.com> Date: Wed, 13 May 2026 11:37:30 +1000 Subject: [PATCH 1/3] feat: add scripts.json schema validation Adds tools/validate_scripts.py (stdlib-only, all errors in one pass) and .github/workflows/validate-scripts-json.yml to validate scripts.json on push/PR. Step 1 of the issue-form submission pipeline. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/validate-scripts-json.yml | 23 ++++ tools/validate_scripts.py | 118 ++++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 .github/workflows/validate-scripts-json.yml create mode 100644 tools/validate_scripts.py diff --git a/.github/workflows/validate-scripts-json.yml b/.github/workflows/validate-scripts-json.yml new file mode 100644 index 0000000..9e15984 --- /dev/null +++ b/.github/workflows/validate-scripts-json.yml @@ -0,0 +1,23 @@ +name: Validate scripts.json + +on: + push: + branches: [main] + paths: + - 'scripts.json' + - 'tools/validate_scripts.py' + - '.github/workflows/validate-scripts-json.yml' + pull_request: + paths: + - 'scripts.json' + - 'tools/validate_scripts.py' + - '.github/workflows/validate-scripts-json.yml' + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v8.1.0 + - name: Validate scripts.json + run: uv run tools/validate_scripts.py --scripts-json scripts.json diff --git a/tools/validate_scripts.py b/tools/validate_scripts.py new file mode 100644 index 0000000..7cda2c5 --- /dev/null +++ b/tools/validate_scripts.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +""" +Validate scripts.json against the expected schema. + +Exit 0 = valid, Exit 1 = errors found. +""" + +import argparse +import json +import sys +from pathlib import Path + +KNOWN_CATEGORIES = { + "Audio & Media", + "Flight Controller Config", + "Games & Fun", + "GPS & Mapping", + "Logging & Analysis", + "Radio Tools", + "Telemetry & Widgets", +} + +REQUIRED_FIELDS = ["name", "category", "description", "infourl", "images", "tags"] +STRING_FIELDS = ["name", "category", "description", "infourl"] + + +def load_and_parse(path: Path) -> list: + if not path.exists(): + print(f"Error: {path} not found", file=sys.stderr) + sys.exit(1) + try: + with open(path, encoding="utf-8") as f: + data = json.load(f) + except json.JSONDecodeError as e: + print(f"Error: invalid JSON in {path}: {e}", file=sys.stderr) + sys.exit(1) + if not isinstance(data, list): + print(f"Error: {path} must be a JSON array", file=sys.stderr) + sys.exit(1) + return data + + +def validate(data: list) -> list[str]: + errors = [] + seen_names: dict[str, int] = {} + + for i, entry in enumerate(data): + prefix = f"Entry {i}" + + if not isinstance(entry, dict): + errors.append(f"{prefix}: not an object") + continue + + for field in REQUIRED_FIELDS: + if field not in entry: + errors.append(f"{prefix}: missing required field '{field}'") + + for field in STRING_FIELDS: + if field in entry: + if not isinstance(entry[field], str) or not entry[field].strip(): + errors.append(f"{prefix}: '{field}' must be a non-empty string") + + category = entry.get("category") + if isinstance(category, str) and category.strip() and category not in KNOWN_CATEGORIES: + errors.append( + f"{prefix}: unknown category '{category}'" + f" (known: {sorted(KNOWN_CATEGORIES)})" + ) + + infourl = entry.get("infourl") + if isinstance(infourl, str) and infourl.strip(): + if not (infourl.startswith("http://") or infourl.startswith("https://")): + errors.append(f"{prefix}: 'infourl' must start with http:// or https://") + + if "images" in entry and not isinstance(entry["images"], list): + errors.append(f"{prefix}: 'images' must be a list") + + if "tags" in entry and not isinstance(entry["tags"], list): + errors.append(f"{prefix}: 'tags' must be a list") + + name = entry.get("name") + if isinstance(name, str) and name.strip(): + key = name.strip().lower() + if key in seen_names: + errors.append( + f"{prefix}: duplicate name '{name}'" + f" (also at entry {seen_names[key]})" + ) + else: + seen_names[key] = i + + return errors + + +def main() -> None: + parser = argparse.ArgumentParser(description="Validate scripts.json schema") + parser.add_argument( + "--scripts-json", + default="scripts.json", + type=Path, + help="Path to scripts.json (default: scripts.json)", + ) + args = parser.parse_args() + + data = load_and_parse(args.scripts_json) + errors = validate(data) + + if errors: + print(f"Found {len(errors)} error(s) in {args.scripts_json}:") + for error in errors: + print(f" - {error}") + sys.exit(1) + + print(f"{args.scripts_json} is valid ({len(data)} entries).") + + +if __name__ == "__main__": + main() From 22412fc56a4d91bf83b4ecd5eee291a3073618b4 Mon Sep 17 00:00:00 2001 From: Peter Feerick <5500713+pfeerick@users.noreply.github.com> Date: Wed, 13 May 2026 11:48:51 +1000 Subject: [PATCH 2/3] fix(validation): handle read errors and warn on missing images --- tools/validate_scripts.py | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/tools/validate_scripts.py b/tools/validate_scripts.py index 7cda2c5..5173126 100644 --- a/tools/validate_scripts.py +++ b/tools/validate_scripts.py @@ -2,7 +2,7 @@ """ Validate scripts.json against the expected schema. -Exit 0 = valid, Exit 1 = errors found. +Exit 0 = valid or warnings only, Exit 1 = errors found. """ import argparse @@ -31,6 +31,9 @@ def load_and_parse(path: Path) -> list: try: with open(path, encoding="utf-8") as f: data = json.load(f) + except OSError as e: + print(f"Error: unable to read {path}: {e}", file=sys.stderr) + sys.exit(1) except json.JSONDecodeError as e: print(f"Error: invalid JSON in {path}: {e}", file=sys.stderr) sys.exit(1) @@ -40,8 +43,9 @@ def load_and_parse(path: Path) -> list: return data -def validate(data: list) -> list[str]: +def validate(data: list) -> tuple[list[str], list[str]]: errors = [] + warnings = [] seen_names: dict[str, int] = {} for i, entry in enumerate(data): @@ -72,11 +76,17 @@ def validate(data: list) -> list[str]: if not (infourl.startswith("http://") or infourl.startswith("https://")): errors.append(f"{prefix}: 'infourl' must start with http:// or https://") - if "images" in entry and not isinstance(entry["images"], list): + if "images" not in entry: + warnings.append(f"{prefix}: 'images' should be a non-empty list") + elif not isinstance(entry["images"], list): errors.append(f"{prefix}: 'images' must be a list") + elif len(entry["images"]) == 0: + warnings.append(f"{prefix}: 'images' should be a non-empty list") - if "tags" in entry and not isinstance(entry["tags"], list): - errors.append(f"{prefix}: 'tags' must be a list") + if "tags" in entry and ( + not isinstance(entry["tags"], list) or len(entry["tags"]) == 0 + ): + errors.append(f"{prefix}: 'tags' must be a non-empty list") name = entry.get("name") if isinstance(name, str) and name.strip(): @@ -89,7 +99,7 @@ def validate(data: list) -> list[str]: else: seen_names[key] = i - return errors + return errors, warnings def main() -> None: @@ -103,7 +113,12 @@ def main() -> None: args = parser.parse_args() data = load_and_parse(args.scripts_json) - errors = validate(data) + errors, warnings = validate(data) + + if warnings: + print(f"Found {len(warnings)} warning(s) in {args.scripts_json}:", file=sys.stderr) + for warning in warnings: + print(f" - {warning}", file=sys.stderr) if errors: print(f"Found {len(errors)} error(s) in {args.scripts_json}:") From e0ccb730e5855f6c330c3a97d27d0812fef5019b Mon Sep 17 00:00:00 2001 From: Peter Feerick <5500713+pfeerick@users.noreply.github.com> Date: Wed, 13 May 2026 11:54:14 +1000 Subject: [PATCH 3/3] fix(validate): make images optional in REQUIRED_FIELDS --- tools/validate_scripts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/validate_scripts.py b/tools/validate_scripts.py index 5173126..fe82ae1 100644 --- a/tools/validate_scripts.py +++ b/tools/validate_scripts.py @@ -20,7 +20,7 @@ "Telemetry & Widgets", } -REQUIRED_FIELDS = ["name", "category", "description", "infourl", "images", "tags"] +REQUIRED_FIELDS = ["name", "category", "description", "infourl", "tags"] STRING_FIELDS = ["name", "category", "description", "infourl"]