Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
2f854da
ci: validate landscape.yml on pull requests
thc1006 Jul 24, 2026
8df7701
ci: validate landscape.yml with the site's own parser (js-yaml)
thc1006 Jul 24, 2026
654741e
ci: fix js-yaml prototype-pollution exposure and harden landscape val…
thc1006 Jul 24, 2026
11b29c8
Harden landscape validation: js-yaml 4.3.0, reject aliases, cap size
thc1006 Jul 24, 2026
d01977b
Bound parsed size and disable merge keys in the landscape validator
thc1006 Jul 25, 2026
c08ae52
Align the browser parser with the validator and bound render and erro…
thc1006 Jul 25, 2026
04c104e
Reject non-string and oversized item fields before building diagnostics
thc1006 Jul 25, 2026
1250d4e
Align the validator's stated guarantees with its behavior
thc1006 Jul 25, 2026
9715ad4
Highlight search results with the same normalized query the filter used
thc1006 Jul 25, 2026
e3cf54e
Bound the parsed object graph, harden the highlighter, and fix docs
thc1006 Jul 25, 2026
b3d7181
Bound highlight nodes per render, correct the graph-budget claims, te…
thc1006 Jul 25, 2026
7841520
Resolve the CLI default path relative to the script, tighten parity t…
thc1006 Jul 26, 2026
4166336
Reject symlinks in the Pages artifact before upload
thc1006 Jul 26, 2026
a7096d1
Match filter and highlight on one regex, reject source symlinks robustly
thc1006 Jul 26, 2026
4c3c35c
Fold Unicode case in search, check JS syntax and symlinks in PR CI
thc1006 Jul 27, 2026
2b4bebc
Test that the browser search regexes stay Unicode-aware
thc1006 Jul 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/validate-landscape.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: Validate Landscape

on:
pull_request:
paths:
- "landscape/landscape.yml"
- "scripts/validate_landscape.py"
- ".github/workflows/validate-landscape.yml"

permissions:
contents: read

jobs:
validate:
runs-on: ubuntu-latest
steps:
- name: Check out
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install PyYAML
run: pip install pyyaml
- name: Validate landscape.yml
run: python scripts/validate_landscape.py landscape/landscape.yml
115 changes: 115 additions & 0 deletions scripts/validate_landscape.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""Validate landscape/landscape.yml against the schema in docs/data-schemas.md.

Checks that the file parses, follows the category -> subcategory -> item
structure, has the required item fields, uses a valid `project` value, keeps
URLs on https, and has no duplicate entry names. Prints every problem it finds
and exits non-zero if there are any, so it can gate pull requests that change
the landscape data.

Usage: python scripts/validate_landscape.py [path/to/landscape.yml]
"""
import sys

try:
import yaml
except ImportError:
print("error: PyYAML is required (pip install pyyaml)", file=sys.stderr)
sys.exit(2)

PROJECT_VALUES = {"graduated", "incubating", "member", "external"}
REQUIRED_ITEM_FIELDS = ("name", "homepage_url", "description", "project")


def is_nonempty_str(value):
return isinstance(value, str) and value.strip() != ""


def validate(path):
errors = []
try:
with open(path, "r", encoding="utf-8") as handle:
data = yaml.safe_load(handle)
except yaml.YAMLError as exc:
return [f"{path}: YAML parse error: {exc}"]
except OSError as exc:
return [f"{path}: cannot read file: {exc}"]

if not isinstance(data, dict) or "landscape" not in data:
return [f"{path}: top-level 'landscape' key is missing"]
categories = data["landscape"]
if not isinstance(categories, list):
return [f"{path}: 'landscape' must be a list of categories"]

seen_names = {} # normalized name -> first location where it appeared
for cat_index, category in enumerate(categories):
if not isinstance(category, dict) or not is_nonempty_str(category.get("category")):
errors.append(f"landscape[{cat_index}]: missing 'category' name")
continue
cat_name = category["category"]
subcategories = category.get("subcategories")
if not isinstance(subcategories, list):
errors.append(f"category '{cat_name}': 'subcategories' must be a list")
continue
for sub_index, subcategory in enumerate(subcategories):
if not isinstance(subcategory, dict) or not is_nonempty_str(subcategory.get("subcategory")):
errors.append(f"category '{cat_name}': subcategory[{sub_index}] missing 'subcategory' name")
continue
sub_name = subcategory["subcategory"]
items = subcategory.get("items")
if not isinstance(items, list):
errors.append(f"'{cat_name}' / '{sub_name}': 'items' must be a list")
continue
for item_index, item in enumerate(items):
location = f"'{cat_name}' / '{sub_name}' / item[{item_index}]"
if not isinstance(item, dict):
errors.append(f"{location}: item must be a mapping")
continue
name = item.get("name")
if is_nonempty_str(name):
location = f"'{cat_name}' / '{sub_name}' / '{name}'"

for field in REQUIRED_ITEM_FIELDS:
if not is_nonempty_str(item.get(field)):
errors.append(f"{location}: missing or empty required field '{field}'")

project = item.get("project")
if is_nonempty_str(project) and project not in PROJECT_VALUES:
errors.append(
f"{location}: project '{project}' is not one of {sorted(PROJECT_VALUES)}"
)

homepage = item.get("homepage_url")
if is_nonempty_str(homepage) and not homepage.startswith("https://"):
errors.append(f"{location}: homepage_url must start with https:// (got '{homepage}')")

if "repo_url" in item:
repo = item.get("repo_url")
if not is_nonempty_str(repo):
errors.append(f"{location}: repo_url is present but empty")
elif not repo.startswith("https://"):
errors.append(f"{location}: repo_url must start with https:// (got '{repo}')")

if is_nonempty_str(name):
key = name.strip().lower()
if key in seen_names:
errors.append(f"{location}: duplicate entry name (also at {seen_names[key]})")
else:
seen_names[key] = location

return errors


def main():
path = sys.argv[1] if len(sys.argv) > 1 else "landscape/landscape.yml"
errors = validate(path)
if errors:
print(f"landscape validation failed with {len(errors)} problem(s):", file=sys.stderr)
for error in errors:
print(f" - {error}", file=sys.stderr)
sys.exit(1)
print(f"{path}: OK")


if __name__ == "__main__":
main()