#!/usr/bin/env python3 """Fail CI when a registered service has no row in the docs/services/index.md matrix. `ResolvedServiceCatalog.java` is the source of truth for which AWS services Floci emulates: every `descriptor("externalKey", "configKey", ...)` call registers one. `docs/services/index.md` is the human-facing matrix users browse to find out what's supported. Nothing previously connected the two, so a service could be registered, enabled and routable while remaining absent from the matrix with every other check green (see floci-io/floci#2454 - four services, and Bedrock AgentCore for five days, shipped without a row). This script closes that gap the same way regen_action_docs.py's unregistered-handler sentinel closes the equivalent gap for action tables: extract descriptor keys from Java source, extract documented filenames from the matrix, and fail on anything that resolves to neither - unless tools/docs/service_matrix.yaml says otherwise (an alias for a key whose AWS signing name differs from its doc filename, or a time-boxed deferred entry for a service that is genuinely not documented yet). The reverse direction is checked too: every docs/services/*.md page must have a matrix row, unless listed under `facet_pages:` in the registry, so a page can no longer exist in the nav with no row (elb-classic.md did, hidden by the `elb` key exact-matching the v2 page). Run from anywhere in the repo: python3 tools/docs/check_service_matrix.py # print warnings only python3 tools/docs/check_service_matrix.py --strict # exit non-zero on warnings """ from __future__ import annotations import argparse import re import sys from dataclasses import dataclass from datetime import date from pathlib import Path import yaml CATALOG_SOURCE = "src/main/java/io/github/hectorvent/floci/core/common/ResolvedServiceCatalog.java" MATRIX_DOC = "docs/services/index.md" REGISTRY = "tools/docs/service_matrix.yaml" MATRIX_HEADING = "## Service Matrix" MATRIX_END_HEADING = "## Common Setup" # The externalKey is the first string literal argument to descriptor(...). It's always # a plain double-quoted literal (no concatenation, no constants) in current usage. DESCRIPTOR_KEY_RE = re.compile(r'\bdescriptor\(\s*"([^"]+)"') # Every descriptor(...) call site, literal-keyed or not - used only to catch a call this # parser's literal-only DESCRIPTOR_KEY_RE would otherwise miss in total silence (a future # externalKey built from a variable or constant instead of a plain string literal). The # negative lookbehind excludes the `private static ServiceDescriptor descriptor(...)` # definition itself, the one place "descriptor(" appears that isn't a call. ALL_DESCRIPTOR_CALLS_RE = re.compile(r"(? list[str]: """externalKey of every descriptor(...) call, in source order. May contain dupes only if the source itself registers the same externalKey twice (a copy-paste bug); the caller dedups via set() before comparing against the matrix. """ return DESCRIPTOR_KEY_RE.findall(java_source) def count_descriptor_calls(java_source: str) -> int: """Total descriptor(...) call sites, regardless of how the first argument is built.""" return len(ALL_DESCRIPTOR_CALLS_RE.findall(java_source)) def extract_matrix_slugs(md_source: str) -> set[str]: """Doc filenames (no extension, no anchor) linked from the Service Matrix table. Scoped to the table between MATRIX_HEADING and MATRIX_END_HEADING so an unrelated markdown link elsewhere on the page (prose, footer) can never be mistaken for a documented row. Raises ValueError if either heading is missing, rather than letting a renamed heading silently empty the table (and every service look undocumented). """ try: start = md_source.index(MATRIX_HEADING) end = md_source.index(MATRIX_END_HEADING, start) except ValueError: raise ValueError( f"{MATRIX_DOC}: could not find '{MATRIX_HEADING}' ... '{MATRIX_END_HEADING}' " "(has a heading been renamed or reordered?)" ) from None table = md_source[start:end] return set(MATRIX_LINK_RE.findall(table)) def find_unlinked_pages(services_dir: Path, slugs: set[str], facet_pages: set[str]) -> list[str]: """docs/services pages (by slug) that no matrix row links and no facet entry excuses. index.md is the matrix page itself and is never expected to link to itself. """ pages = sorted(p.stem for p in services_dir.glob("*.md") if p.name != "index.md") return [page for page in pages if page not in slugs and page not in facet_pages] def _load_registry(repo_root: Path) -> tuple[dict[str, str], list[DeferredEntry], set[str]]: """Load service_matrix.yaml. Returns (aliases, deferred entries, facet pages). Schema: `aliases:` maps an externalKey to the doc slug that documents it; `deferred:` lists externalKeys with no row yet, each requiring `reason` and a `by` expiry date (see service_matrix.yaml's header comment for why expiry is mandatory rather than a permanent bypass); `facet_pages:` lists docs/services slugs that deliberately have no matrix row of their own. """ raw = yaml.safe_load((repo_root / REGISTRY).read_text(encoding="utf-8")) or {} aliases = dict(raw.get("aliases") or {}) facet_pages = set(raw.get("facet_pages") or []) deferred: list[DeferredEntry] = [] for item in raw.get("deferred") or []: missing = [f for f in ("key", "reason", "by") if f not in item] if missing: raise ValueError( f"{REGISTRY}: deferred entry {item!r} is missing required field(s): {missing}" ) deferred.append( DeferredEntry( key=item["key"], reason=item["reason"], by=date.fromisoformat(str(item["by"])), ) ) return aliases, deferred, facet_pages def find_undocumented( keys: list[str], slugs: set[str], aliases: dict[str, str], deferred: list[DeferredEntry], today: date, ) -> tuple[list[str], list[DeferredEntry]]: """Classify descriptor keys against the matrix. Returns (undocumented, expired_deferred): - undocumented: keys that resolve to no matrix row by exact match, alias, or an unexpired deferred entry. Includes keys whose only deferred entry has expired. - expired_deferred: the deferred entries (still present in the registry) whose `by` date has passed - reported separately so the warning names the date and reason instead of reading like a brand-new, unexplained gap. """ active_deferred = {d.key: d for d in deferred if d.by >= today} expired = [d for d in deferred if d.by < today] undocumented: list[str] = [] for key in sorted(set(keys)): target = aliases.get(key, key) if target in slugs: continue if key in active_deferred: continue undocumented.append(key) expired_deferred = [d for d in expired if d.key in set(keys) and aliases.get(d.key, d.key) not in slugs] return undocumented, expired_deferred def _repo_root() -> Path: return Path(__file__).resolve().parent.parent.parent def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--strict", action="store_true", help="treat warnings (undocumented services, expired deferrals) as errors", ) args = parser.parse_args(argv) repo_root = _repo_root() try: aliases, deferred, facet_pages = _load_registry(repo_root) except (ValueError, yaml.YAMLError) as exc: print(f"error: {exc}", file=sys.stderr) return 1 java_source = (repo_root / CATALOG_SOURCE).read_text(encoding="utf-8") keys = extract_descriptor_keys(java_source) if not keys: print(f"error: no descriptor(...) calls found in {CATALOG_SOURCE}", file=sys.stderr) return 1 total_calls = count_descriptor_calls(java_source) if total_calls != len(keys): # DESCRIPTOR_KEY_RE only matches a plain double-quoted externalKey. A call built # from a variable or constant would be silently invisible to every check below - # this is the one place that gap gets caught instead of just under-counting. print( f"error: {CATALOG_SOURCE} has {total_calls} descriptor(...) call(s) but only " f"{len(keys)} have a plain string-literal externalKey as the first argument; " "the rest are invisible to this script and need a parser update", file=sys.stderr, ) return 1 md_source = (repo_root / MATRIX_DOC).read_text(encoding="utf-8") try: slugs = extract_matrix_slugs(md_source) except ValueError as exc: print(f"error: {exc}", file=sys.stderr) return 1 undocumented, expired_deferred = find_undocumented( keys, slugs, aliases, deferred, date.today() ) warnings: list[str] = [] for d in expired_deferred: warnings.append( f"deferred entry '{d.key}' expired on {d.by.isoformat()} ({d.reason}); " f"add its docs/services/index.md row or renew the expiry in {REGISTRY}" ) still_undocumented = set(undocumented) - {d.key for d in expired_deferred} for key in sorted(still_undocumented): warnings.append( f"service '{key}' is registered in {CATALOG_SOURCE} but has no row in " f"{MATRIX_DOC} (add a row, an alias, or a time-boxed entry in {REGISTRY})" ) for page in find_unlinked_pages(repo_root / SERVICES_DIR, slugs, facet_pages): warnings.append( f"{SERVICES_DIR}/{page}.md has no row in {MATRIX_DOC} (add a row, or list it " f"under facet_pages in {REGISTRY} if it is a facet of another service's page)" ) for w in warnings: print(f"warning: {w}", file=sys.stderr) if args.strict and warnings: return 1 return 0 if __name__ == "__main__": sys.exit(main())