| 1 | #!/usr/bin/env python3 |
| 2 | """Check that docs/PROVIDERS.md tracks the shipped provider registry. |
| 3 | |
| 4 | This is intentionally lightweight. It does not try to generate prose; it checks |
| 5 | the stable identifiers and default strings that are easy for docs to drift from: |
| 6 | |
| 7 | - canonical ProviderKind IDs |
| 8 | - provider TOML tables |
| 9 | - descriptor-owned released presentation identities |
| 10 | - shipped-provider table rows |
| 11 | - static ModelRegistry provider rows |
| 12 | - default provider model/base URL constants |
| 13 | """ |
| 14 | |
| 15 | from __future__ import annotations |
| 16 | |
| 17 | import json |
| 18 | import re |
| 19 | import sys |
| 20 | from pathlib import Path |
| 21 | |
| 22 | |
| 23 | ROOT = Path(__file__).resolve().parents[1] |
| 24 | CONFIG_RS = ROOT / "crates" / "config" / "src" / "lib.rs" |
| 25 | # ProviderKind's enum + identity impl were split out of lib.rs into this module. |
| 26 | PROVIDER_KIND_RS = ROOT / "crates" / "config" / "src" / "provider_kind.rs" |
| 27 | PROVIDER_RS = ROOT / "crates" / "config" / "src" / "provider.rs" |
| 28 | TUI_CONFIG_RS = ROOT / "crates" / "tui" / "src" / "config.rs" |
| 29 | # Default provider model/base-URL constants were split out of config.rs into |
| 30 | # this leaf module (#3311); read them from there for the default-string check. |
| 31 | PROVIDER_DATA = ROOT / "crates" / "config" / "assets" / "provider_descriptors.json" |
| 32 | AGENT_RS = ROOT / "crates" / "agent" / "src" / "lib.rs" |
| 33 | PROVIDERS_MD = ROOT / "docs" / "PROVIDERS.md" |
| 34 | CONFIGURATION_MD = ROOT / "docs" / "CONFIGURATION.md" |
| 35 | WEB_FACTS_LIB = ROOT / "web" / "scripts" / "facts-lib.mjs" |
| 36 | WEB_FACTS_DRIFT = ROOT / "web" / "lib" / "facts-drift.ts" |
| 37 | WEB_FACTS_GENERATED = ROOT / "web" / "lib" / "facts.generated.ts" |
| 38 | README_MD = ROOT / "README.md" |
| 39 | CONFIG_EXAMPLE_TOML = ROOT / "config.example.toml" |
| 40 | TUI_PROVIDER_READINESS_RS = ROOT / "crates" / "tui" / "src" / "provider_readiness.rs" |
| 41 | TUI_LIB_RS = ROOT / "crates" / "tui" / "src" / "lib.rs" |
| 42 | |
| 43 | |
| 44 | LEGACY_PROVIDER_TOMBSTONE_IDS = {"antigravity"} |
| 45 | LEGACY_PROVIDER_TOMBSTONE_TABLES = {"antigravity"} |
| 46 | LEGACY_PROVIDER_SELECTION_IDS = {"antigravity", "agy"} |
| 47 | |
| 48 | # `custom` is the dynamic OpenAI-compatible meta-provider (#1519): a single |
| 49 | # catch-all `[providers.custom]` table that backs arbitrary user-defined |
| 50 | # endpoints, not a canonical shipped provider with a docs row. It is excluded |
| 51 | # from the provider-table drift check. |
| 52 | META_PROVIDER_TABLES = {"custom"} |
| 53 | SHARED_PROVIDER_TABLES = { |
| 54 | "siliconflow-CN": "siliconflow_cn", |
| 55 | } |
| 56 | HUGGINGFACE_ALIASES = {"huggingface", "hugging-face", "hugging_face", "hf"} |
| 57 | HUGGINGFACE_API_KEY_ENV_ORDER = ["HUGGINGFACE_API_KEY", "HF_TOKEN"] |
| 58 | HUGGINGFACE_BASE_URL_ENV_ORDER = ["HUGGINGFACE_BASE_URL", "HF_BASE_URL"] |
| 59 | HUGGINGFACE_MODEL_ENV_ORDER = ["HUGGINGFACE_MODEL", "HF_MODEL"] |
| 60 | SENSITIVE_IDENTIFIER_RE = re.compile(r"(?i)(api[_-]?key|token|secret|password|credential)") |
| 61 | SENSITIVE_BEARER_RE = re.compile(r"(?i)(authorization:\s*bearer\s+)\S+") |
| 62 | SENSITIVE_ASSIGNMENT_RE = re.compile( |
| 63 | r"(?i)\b(api[_-]?key|token|secret|password|credential)(\s*[:=]\s*)\S+" |
| 64 | ) |
| 65 | |
| 66 | |
| 67 | def read(path: Path) -> str: |
| 68 | return path.read_text(encoding="utf-8") |
| 69 | |
| 70 | |
| 71 | def display_public_value(value: str) -> str: |
| 72 | if SENSITIVE_IDENTIFIER_RE.search(value): |
| 73 | return "<redacted sensitive identifier>" |
| 74 | return value |
| 75 | |
| 76 | |
| 77 | def redact_sensitive_text(value: str) -> str: |
| 78 | value = SENSITIVE_BEARER_RE.sub(r"\1<redacted>", value) |
| 79 | value = SENSITIVE_ASSIGNMENT_RE.sub(r"\1\2<redacted>", value) |
| 80 | return SENSITIVE_IDENTIFIER_RE.sub("<redacted sensitive identifier>", value) |
| 81 | |
| 82 | |
| 83 | def require_index(source: str, needle: str, context: str, start: int = 0) -> int: |
| 84 | try: |
| 85 | return source.index(needle, start) |
| 86 | except ValueError: |
| 87 | raise ValueError(f"{context}: missing {needle!r}") from None |
| 88 | |
| 89 | |
| 90 | def markdown_section(source: str, heading: str) -> str: |
| 91 | start = require_index(source, heading, "docs/PROVIDERS.md") |
| 92 | next_heading = source.find("\n## ", start + len(heading)) |
| 93 | end = len(source) if next_heading == -1 else next_heading |
| 94 | return source[start:end] |
| 95 | |
| 96 | |
| 97 | def extract_match_block( |
| 98 | source: str, signature: str, context: str, start: int = 0 |
| 99 | ) -> str: |
| 100 | start = require_index(source, signature, context, start) |
| 101 | match_start = require_index(source, "match", f"match block after {signature!r}", start) |
| 102 | brace_start = require_index(source, "{", f"match block after {signature!r}", match_start) |
| 103 | depth = 0 |
| 104 | for index in range(brace_start, len(source)): |
| 105 | char = source[index] |
| 106 | if char == "{": |
| 107 | depth += 1 |
| 108 | elif char == "}": |
| 109 | depth -= 1 |
| 110 | if depth == 0: |
| 111 | return source[brace_start + 1 : index] |
| 112 | raise ValueError(f"could not parse match block after {signature!r}") |
| 113 | |
| 114 | |
| 115 | def provider_data() -> dict: |
| 116 | data = json.loads(read(PROVIDER_DATA)) |
| 117 | if data.get("schema_version") != 3 or len(data.get("providers", [])) != 52: |
| 118 | raise ValueError("unsupported or incomplete provider metadata") |
| 119 | rows = [*data["providers"], data.get("legacy_tui", {})] |
| 120 | fields = ("id", "kind", "tui_wire_tag", "config_key", "catalog_id", "catalog_source_id") |
| 121 | if len(rows) != 53 or any(any(not isinstance(row.get(field), str) or not row[field] for field in fields) for row in rows): |
| 122 | raise ValueError("incomplete released presentation metadata") |
| 123 | for field in ("id", "tui_wire_tag", "tui_order"): |
| 124 | if len({row.get(field) for row in rows}) != len(rows): |
| 125 | raise ValueError(f"duplicate presentation {field}") |
| 126 | if {row["tui_order"] for row in rows} != set(range(len(rows))): |
| 127 | raise ValueError("noncontiguous released presentation order") |
| 128 | if data["legacy_tui"]["kind"] not in {row["kind"] for row in data["providers"]}: |
| 129 | raise ValueError("legacy presentation has no intrinsic provider") |
| 130 | return data |
| 131 | |
| 132 | |
| 133 | def parse_aliases_for_variant(source: str, enum_name: str, variant: str, context: str) -> set[str]: |
| 134 | # Both selectors delegate aliases to the same descriptor-backed facade. |
| 135 | for row in provider_data()["providers"]: |
| 136 | if row["kind"] == variant: |
| 137 | return {row["id"], *row["aliases"]} |
| 138 | raise ValueError(f"{context}: missing descriptor for {variant}") |
| 139 | |
| 140 | |
| 141 | def provider_kind_ids(config_rs: str) -> dict[str, str]: |
| 142 | rows = provider_data()["providers"] |
| 143 | ids = {row["kind"]: row["id"] for row in rows if row["kind"] != "Custom"} |
| 144 | if len({row["kind"] for row in rows}) != len(rows) or len({row["id"] for row in rows}) != len(rows): |
| 145 | raise ValueError("duplicate built-in descriptor identity") |
| 146 | return ids |
| 147 | |
| 148 | |
| 149 | def provider_kind_catalog_ids(provider_kind_rs: str, variant_to_id: dict[str, str]) -> set[str]: |
| 150 | if not re.search(r"pub const ALL:.*?=\s*crate::descriptors::SELECTABLE_PROVIDER_KINDS;", provider_kind_rs): |
| 151 | raise ValueError("ProviderKind::ALL must use generated descriptor selection") |
| 152 | return {row["id"] for row in provider_data()["providers"] if row["selectable"]} |
| 153 | |
| 154 | |
| 155 | def presentation_provider_ids() -> set[str]: |
| 156 | data = provider_data() |
| 157 | return {row["id"] for row in [*data["providers"], data["legacy_tui"]] if row["kind"] != "Custom"} |
| 158 | |
| 159 | |
| 160 | def provider_tables(config_rs: str) -> set[str]: |
| 161 | struct_start = require_index( |
| 162 | config_rs, "pub struct ProvidersToml", "crates/config/src/lib.rs" |
| 163 | ) |
| 164 | struct_end = require_index(config_rs, "\n}", "ProvidersToml struct", struct_start) |
| 165 | fields = re.findall( |
| 166 | r"pub\s+([a-z0-9_]+)\s*:\s*ProviderConfigToml", |
| 167 | config_rs[struct_start:struct_end], |
| 168 | ) |
| 169 | if not fields: |
| 170 | raise ValueError("ProvidersToml returned no provider tables") |
| 171 | return set(fields) |
| 172 | |
| 173 | |
| 174 | def shipped_provider_rows(providers_md: str) -> set[str]: |
| 175 | table = markdown_section(providers_md, "## Shipped Providers") |
| 176 | return set(re.findall(r"^\|\s*`([^`]+)`\s*\|", table, flags=re.MULTILINE)) |
| 177 | |
| 178 | |
| 179 | def shipped_provider_tables(providers_md: str) -> set[str]: |
| 180 | table = markdown_section(providers_md, "## Shipped Providers") |
| 181 | return set(re.findall(r"\|\s*`\[providers\.([a-z0-9_]+)\]`\s*\|", table)) |
| 182 | |
| 183 | |
| 184 | def documented_selectable_provider_ids(providers_md: str) -> set[str]: |
| 185 | marker = require_index(providers_md, "in that order:", "docs/PROVIDERS.md") |
| 186 | start = require_index(providers_md, "\n\n", "provider selection list", marker) + 2 |
| 187 | end = require_index(providers_md, "\n\n", "provider selection list", start) |
| 188 | return set(re.findall(r"`([^`]+)`", providers_md[start:end])) |
| 189 | |
| 190 | |
| 191 | def report_provider_kind_selector_contract(provider_kind_rs: str) -> list[str]: |
| 192 | start = require_index( |
| 193 | provider_kind_rs, |
| 194 | "pub fn parse(value: &str) -> Option<Self>", |
| 195 | "ProviderKind::parse", |
| 196 | ) |
| 197 | end = require_index( |
| 198 | provider_kind_rs, "pub fn parse_config_identity", "ProviderKind::parse", start |
| 199 | ) |
| 200 | selector = provider_kind_rs[start:end] |
| 201 | if "Self::ALL" not in selector and "Self::all()" not in selector: |
| 202 | return [ |
| 203 | "ProviderKind::parse must gate registry aliases through the selectable " |
| 204 | "ProviderKind::ALL catalog" |
| 205 | ] |
| 206 | return [] |
| 207 | |
| 208 | |
| 209 | def report_tui_catalog_contract(tui_config_rs: str) -> list[str]: |
| 210 | errors = [] |
| 211 | if re.search(r"(?:enum|impl|type)\s+ApiProvider\b|KIND_LOOKUP|FROM_KIND_LOOKUP", tui_config_rs): |
| 212 | errors.append("TUI must not define a duplicate provider enum or ordinal bridge") |
| 213 | # The exact private historical enum in config/tests.rs is the serde |
| 214 | # counterpart, not a production owner. Check every other Rust consumer. |
| 215 | counterpart = ROOT / "crates/tui/src/config/tests.rs" |
| 216 | for path in (ROOT / "crates").rglob("*.rs"): |
| 217 | source = read(path) |
| 218 | if path != counterpart and re.search(r"\bApiProvider\b", source): |
| 219 | errors.append(f"retired provider enum consumer: {path.relative_to(ROOT)}") |
| 220 | if re.search(r"KIND_LOOKUP|FROM_KIND_LOOKUP|ProviderKind::from_kind\(", source): |
| 221 | errors.append(f"retired provider ordinal bridge: {path.relative_to(ROOT)}") |
| 222 | for signature in ("pub(crate) fn active_provider_identity", "pub(crate) fn provider_identities", "pub(crate) fn resolve_provider_selection_identity", "pub(crate) fn verify_provider_identity"): |
| 223 | if signature not in tui_config_rs: |
| 224 | errors.append(f"missing canonical config identity boundary: {signature}") |
| 225 | descriptor_source = read(ROOT / "crates/config/src/descriptors.rs") |
| 226 | if "PROVIDER_COMPATIBILITY" not in descriptor_source or "pub fn compatibility_for_selector" not in descriptor_source: |
| 227 | errors.append("released presentation identities must use the existing generated descriptor owner") |
| 228 | if "is_legacy_antigravity_identity(requested)" not in tui_config_rs: |
| 229 | errors.append("explicit provider selection must refuse retired Antigravity aliases") |
| 230 | return errors |
| 231 | |
| 232 | |
| 233 | def report_tombstone_runtime_contract( |
| 234 | provider_kind_rs: str, tui_provider_readiness_rs: str, tui_lib_rs: str |
| 235 | ) -> list[str]: |
| 236 | """The tombstone must resolve under every legacy spelling and never read |
| 237 | as a credentialed or advertised slot on a running-product surface.""" |
| 238 | |
| 239 | errors: list[str] = [] |
| 240 | start = require_index( |
| 241 | provider_kind_rs, |
| 242 | "pub fn parse_config_identity(value: &str) -> Option<Self>", |
| 243 | "ProviderKind::parse_config_identity", |
| 244 | ) |
| 245 | end = require_index( |
| 246 | provider_kind_rs, "pub fn secret_store_slot", "ProviderKind::parse_config_identity", start |
| 247 | ) |
| 248 | config_identity = provider_kind_rs[start:end] |
| 249 | if "parse_retired_alias" not in config_identity: |
| 250 | errors.append( |
| 251 | "ProviderKind::parse_config_identity must resolve retired registry aliases " |
| 252 | "(`agy`) so every selection surface can name the tombstone" |
| 253 | ) |
| 254 | |
| 255 | if ( |
| 256 | "provider == ProviderKind::Antigravity" |
| 257 | not in tui_provider_readiness_rs |
| 258 | ): |
| 259 | errors.append( |
| 260 | "provider_readiness::credential_state_for_provider must classify " |
| 261 | "ProviderKind::Antigravity as CredentialState::Legacy" |
| 262 | ) |
| 263 | |
| 264 | if "for provider in doctor_api_key_providers()" not in tui_lib_rs or ( |
| 265 | "*provider != crate::config::ProviderKind::Antigravity" not in tui_lib_rs |
| 266 | ): |
| 267 | errors.append( |
| 268 | "`codewhale doctor` API Keys rows must iterate doctor_api_key_providers() " |
| 269 | "with the retired Antigravity slot filtered out" |
| 270 | ) |
| 271 | return errors |
| 272 | |
| 273 | |
| 274 | def report_antigravity_public_contract( |
| 275 | providers_md: str, |
| 276 | configuration_md: str, |
| 277 | web_facts_lib: str, |
| 278 | web_facts_drift: str, |
| 279 | web_facts_generated: str, |
| 280 | readme_md: str, |
| 281 | config_example_toml: str, |
| 282 | ) -> list[str]: |
| 283 | """Keep the retired provider as one safe, non-runnable docs tombstone.""" |
| 284 | |
| 285 | errors: list[str] = [] |
| 286 | heading = "### Legacy Antigravity tombstone" |
| 287 | heading_count = providers_md.count(heading) |
| 288 | if heading_count != 1: |
| 289 | errors.append( |
| 290 | "docs/PROVIDERS.md must contain exactly one legacy Antigravity tombstone " |
| 291 | f"heading (found {heading_count})" |
| 292 | ) |
| 293 | tombstone = "" |
| 294 | outside_tombstone = providers_md |
| 295 | else: |
| 296 | start = providers_md.index(heading) |
| 297 | next_heading = re.search(r"\n#{1,3} ", providers_md[start + len(heading) :]) |
| 298 | end = ( |
| 299 | len(providers_md) |
| 300 | if next_heading is None |
| 301 | else start + len(heading) + next_heading.start() |
| 302 | ) |
| 303 | tombstone = providers_md[start:end] |
| 304 | outside_tombstone = providers_md[:start] + providers_md[end:] |
| 305 | |
| 306 | normalized_tombstone = " ".join(tombstone.split()) |
| 307 | required_tombstone_copy = [ |
| 308 | "not a Codewhale provider", |
| 309 | "cannot be selected or run", |
| 310 | "non-runnable migration tombstone", |
| 311 | "`codewhale auth clear --provider antigravity`", |
| 312 | "Codewhale-owned legacy configuration and consent metadata", |
| 313 | "does not sign out of, revoke, read, or otherwise alter any official Google or Antigravity session", |
| 314 | "supported `google` provider", |
| 315 | "`GEMINI_API_KEY`", |
| 316 | ] |
| 317 | missing_tombstone_copy = [ |
| 318 | required |
| 319 | for required in required_tombstone_copy |
| 320 | if required not in normalized_tombstone |
| 321 | ] |
| 322 | if missing_tombstone_copy: |
| 323 | errors.append( |
| 324 | "legacy Antigravity tombstone is missing required safety or migration copy " |
| 325 | f"({len(missing_tombstone_copy)} checks failed)" |
| 326 | ) |
| 327 | clear_command = "`codewhale auth clear --provider antigravity`" |
| 328 | legacy_provider_forms = [ |
| 329 | match.lower() |
| 330 | for match in re.findall( |
| 331 | r"--provider\s+(antigravity|agy)\b", providers_md, flags=re.IGNORECASE |
| 332 | ) |
| 333 | ] |
| 334 | if providers_md.count(clear_command) != 1 or legacy_provider_forms != [ |
| 335 | "antigravity" |
| 336 | ]: |
| 337 | errors.append( |
| 338 | "docs/PROVIDERS.md must contain the Codewhale-owned Antigravity " |
| 339 | "clear command as its only --provider antigravity/agy form" |
| 340 | ) |
| 341 | setup_guidance = re.search( |
| 342 | r"\bagy\b|\boauth\b|\blog(?:in|\s+in)\b|\bsign\s+in\b|" |
| 343 | r"\bimport\b|\bexternal-consent\b|/provider\s+(?:antigravity|agy)\b|" |
| 344 | r"CODEWHALE_PROVIDER\s*=\s*(?:antigravity|agy)\b", |
| 345 | tombstone, |
| 346 | flags=re.IGNORECASE, |
| 347 | ) |
| 348 | if setup_guidance: |
| 349 | errors.append( |
| 350 | "legacy Antigravity tombstone contains login, OAuth import, consent, " |
| 351 | "or provider-selection guidance" |
| 352 | ) |
| 353 | |
| 354 | if re.search(r"\b(?:antigravity|agy)\b", outside_tombstone, flags=re.IGNORECASE): |
| 355 | errors.append( |
| 356 | "docs/PROVIDERS.md mentions Antigravity/agy outside its legacy tombstone" |
| 357 | ) |
| 358 | if re.search(r"\b(?:antigravity|agy)\b", configuration_md, flags=re.IGNORECASE): |
| 359 | errors.append("docs/CONFIGURATION.md advertises retired Antigravity state") |
| 360 | if re.search(r"\b(?:antigravity|agy)\b", readme_md, flags=re.IGNORECASE): |
| 361 | errors.append("README.md advertises retired Antigravity state") |
| 362 | if re.search(r"\b(?:antigravity|agy)\b", config_example_toml, flags=re.IGNORECASE): |
| 363 | errors.append("config.example.toml advertises retired Antigravity state") |
| 364 | if "[providers.google]" not in config_example_toml or not re.search( |
| 365 | r"GEMINI_API_KEY", config_example_toml |
| 366 | ): |
| 367 | errors.append( |
| 368 | "config.example.toml must document the supported `google` Gemini route " |
| 369 | "with GEMINI_API_KEY" |
| 370 | ) |
| 371 | |
| 372 | forbidden_markers = { |
| 373 | "Antigravity API-key environment guidance": "ANTIGRAVITY_API_KEY", |
| 374 | "Antigravity ADC environment guidance": "AGY_ADC_AUTH", |
| 375 | "Antigravity base-URL environment guidance": "ANTIGRAVITY_BASE_URL", |
| 376 | "Antigravity model environment guidance": "ANTIGRAVITY_MODEL", |
| 377 | "private cloud-code endpoint guidance": "cloudcode-pa", |
| 378 | "private cloud-code protocol guidance": "cloud-code", |
| 379 | "official CLI credential-store guidance": "state.vscdb", |
| 380 | "official CLI OAuth-state guidance": "antigravityUnifiedStateSync", |
| 381 | "runnable legacy provider selection": 'provider = "antigravity"', |
| 382 | "runnable legacy provider table": "[providers.antigravity]", |
| 383 | } |
| 384 | public_sources = { |
| 385 | "docs/PROVIDERS.md": providers_md, |
| 386 | "docs/CONFIGURATION.md": configuration_md, |
| 387 | "web/scripts/facts-lib.mjs": web_facts_lib, |
| 388 | "web/lib/facts-drift.ts": web_facts_drift, |
| 389 | "web/lib/facts.generated.ts": web_facts_generated, |
| 390 | "README.md": readme_md, |
| 391 | "config.example.toml": config_example_toml, |
| 392 | } |
| 393 | for context, source in public_sources.items(): |
| 394 | for description, marker in forbidden_markers.items(): |
| 395 | if marker.lower() in source.lower(): |
| 396 | errors.append(f"{context} contains forbidden {description}") |
| 397 | |
| 398 | projection = read(ROOT / "web/lib/provider-descriptors.mjs") |
| 399 | if 'row.retired || row.kind === "Antigravity" || row.kind === "Custom"' not in projection: |
| 400 | errors.append("shared website descriptor projection must exclude retired/Antigravity/custom rows") |
| 401 | for context, source in [("web/scripts/facts-lib.mjs", web_facts_lib), ("web/lib/facts-drift.ts", web_facts_drift)]: |
| 402 | if "parseProviderDescriptors" not in source: |
| 403 | errors.append(f"{context} must use the shared validated descriptor projection") |
| 404 | if re.search(r"^\s*Antigravity\s*:", source, flags=re.MULTILINE): |
| 405 | errors.append(f"{context} maps legacy Antigravity to public provider facts") |
| 406 | if re.search(r"\bagy\b", source, flags=re.IGNORECASE): |
| 407 | errors.append(f"{context} exposes the legacy agy alias") |
| 408 | |
| 409 | if re.search( |
| 410 | r"\b(?:antigravity|agy)\b", web_facts_generated, flags=re.IGNORECASE |
| 411 | ): |
| 412 | errors.append("web/lib/facts.generated.ts exposes legacy Antigravity/agy") |
| 413 | |
| 414 | return errors |
| 415 | |
| 416 | |
| 417 | def static_registry_provider_rows(providers_md: str) -> set[str]: |
| 418 | table = markdown_section(providers_md, "## Static Model Registry") |
| 419 | return set(re.findall(r"^\|\s*`([^`]+)`\s*\|", table, flags=re.MULTILINE)) |
| 420 | |
| 421 | |
| 422 | def model_registry_providers(agent_rs: str, variant_to_id: dict[str, str]) -> set[str]: |
| 423 | # ModelRegistry is now a compatibility projection of the reviewed catalog. |
| 424 | # Reading tests or caller literals cannot reconstruct its shipping roster. |
| 425 | data = json.loads(read(ROOT / "crates/config/assets/catalog_corrections.json")) |
| 426 | reviewed = data.get("reviewed") |
| 427 | if not isinstance(reviewed, dict) or not reviewed.get("revision"): |
| 428 | raise ValueError("reviewed catalog metadata missing") |
| 429 | rows = reviewed.get("selections") |
| 430 | if not isinstance(rows, list) or not rows: |
| 431 | raise ValueError("reviewed catalog selections missing") |
| 432 | ids = {row.get("provider") for row in rows if isinstance(row, dict)} |
| 433 | if None in ids or ids - set(variant_to_id.values()): |
| 434 | raise ValueError("reviewed catalog uses missing/unknown provider identity") |
| 435 | if not re.search(r"bundled_reviewed\(\)\s*\.selections", agent_rs): |
| 436 | raise ValueError("ModelRegistry must project the shared reviewed selection rows") |
| 437 | return ids |
| 438 | |
| 439 | |
| 440 | def default_strings(tui_config_rs: str) -> set[str]: |
| 441 | data = provider_data() |
| 442 | rows = {row["id"]: row for row in data["providers"]} |
| 443 | values = dict(data["compatibility_constants"]) |
| 444 | for name, ref in data["constant_refs"].items(): |
| 445 | row = rows[ref["provider"]] |
| 446 | values[name] = (row["credential_help"] if ref["field"] == "credential_url" else row)[ref["field"]] |
| 447 | defaults = {value for name, value in values.items() |
| 448 | if re.fullmatch(r"DEFAULT_[A-Z0-9_]+(?:MODEL|BASE_URL)", name) |
| 449 | and name != "DEFAULT_DEEPSEEKCN_BASE_URL" |
| 450 | and not name.startswith("DEFAULT_ANTIGRAVITY_")} |
| 451 | if not defaults: |
| 452 | raise ValueError("no default provider metadata found") |
| 453 | return defaults |
| 454 | |
| 455 | |
| 456 | def missing_default_strings(providers_md: str, defaults: set[str]) -> list[str]: |
| 457 | # Inline-code validation should not let fenced TOML/bash examples pair a |
| 458 | # stray backtick with later prose; strip fenced blocks before scanning. |
| 459 | inline_source = re.sub(r"```.*?```", "", providers_md, flags=re.DOTALL) |
| 460 | code_spans = set(re.findall(r"`([^`]+)`", inline_source)) |
| 461 | return sorted(defaults - code_spans) |
| 462 | |
| 463 | |
| 464 | def report_set(label: str, expected: set[str], actual: set[str]) -> list[str]: |
| 465 | errors = [] |
| 466 | missing = sorted(expected - actual) |
| 467 | extra = sorted(actual - expected) |
| 468 | if missing: |
| 469 | errors.append(f"{label} missing: {', '.join(missing)}") |
| 470 | if extra: |
| 471 | errors.append(f"{label} extra: {', '.join(extra)}") |
| 472 | return errors |
| 473 | |
| 474 | |
| 475 | def report_presentation_identity_drift(canonical_ids: set[str], presentation_ids: set[str]) -> list[str]: |
| 476 | legacy = provider_data()["legacy_tui"]["id"] |
| 477 | return report_set("released presentation identities", canonical_ids | {legacy}, presentation_ids) |
| 478 | |
| 479 | |
| 480 | def report_huggingface_coverage( |
| 481 | config_rs: str, provider_rs: str, tui_config_rs: str, providers_md: str |
| 482 | ) -> list[str]: |
| 483 | errors = [] |
| 484 | |
| 485 | config_aliases = parse_aliases_for_variant( |
| 486 | config_rs, "ProviderKind", "Huggingface", "crates/config/src/lib.rs" |
| 487 | ) |
| 488 | tui_aliases = parse_aliases_for_variant( |
| 489 | tui_config_rs, "ProviderIdentity", "Huggingface", "crates/tui/src/config.rs" |
| 490 | ) |
| 491 | errors += report_set( |
| 492 | "ProviderKind Hugging Face aliases", |
| 493 | HUGGINGFACE_ALIASES, |
| 494 | config_aliases & HUGGINGFACE_ALIASES, |
| 495 | ) |
| 496 | errors += report_set( |
| 497 | "descriptor presentation Hugging Face aliases", |
| 498 | HUGGINGFACE_ALIASES, |
| 499 | tui_aliases & HUGGINGFACE_ALIASES, |
| 500 | ) |
| 501 | |
| 502 | inline_source = re.sub(r"```.*?```", "", providers_md, flags=re.DOTALL) |
| 503 | code_spans = set(re.findall(r"`([^`]+)`", inline_source)) |
| 504 | errors += report_set( |
| 505 | "documented Hugging Face aliases", |
| 506 | HUGGINGFACE_ALIASES, |
| 507 | code_spans & HUGGINGFACE_ALIASES, |
| 508 | ) |
| 509 | |
| 510 | # API-key lookup order is descriptor-owned; actual TUI environment |
| 511 | # resolution remains independently checked below. |
| 512 | auth_label = "Hugging Face auth env precedence" |
| 513 | row = next(row for row in provider_data()["providers"] if row["kind"] == "Huggingface") |
| 514 | if row["env_vars"] != HUGGINGFACE_API_KEY_ENV_ORDER: |
| 515 | errors.append("Hugging Face descriptor auth env precedence differs") |
| 516 | for label, env_order in [ |
| 517 | (auth_label, HUGGINGFACE_API_KEY_ENV_ORDER), |
| 518 | ("Hugging Face base URL env precedence", HUGGINGFACE_BASE_URL_ENV_ORDER), |
| 519 | ("Hugging Face model env precedence", HUGGINGFACE_MODEL_ENV_ORDER), |
| 520 | ]: |
| 521 | if label != auth_label: |
| 522 | errors += report_env_lookup_order( |
| 523 | label, config_rs, env_order, "crates/config/src/lib.rs" |
| 524 | ) |
| 525 | errors += report_env_lookup_order( |
| 526 | label, tui_config_rs, env_order, "crates/tui/src/config.rs" |
| 527 | ) |
| 528 | errors += report_string_order(label, providers_md, env_order, "docs/PROVIDERS.md") |
| 529 | |
| 530 | return errors |
| 531 | |
| 532 | |
| 533 | def report_env_lookup_order( |
| 534 | label: str, source: str, expected_order: list[str], context: str |
| 535 | ) -> list[str]: |
| 536 | lookup_needles = [f'std::env::var("{name}")' for name in expected_order] |
| 537 | return report_string_order(label, source, lookup_needles, context) |
| 538 | |
| 539 | |
| 540 | def report_string_order( |
| 541 | label: str, source: str, expected_order: list[str], context: str |
| 542 | ) -> list[str]: |
| 543 | contains_sensitive_expected_value = any( |
| 544 | SENSITIVE_IDENTIFIER_RE.search(value) for value in expected_order |
| 545 | ) |
| 546 | positions = [] |
| 547 | for needle in expected_order: |
| 548 | index = source.find(needle) |
| 549 | if index == -1: |
| 550 | if contains_sensitive_expected_value: |
| 551 | return [f"{label} missing required entry in {context}"] |
| 552 | return [f"{label} missing {display_public_value(needle)!r} in {context}"] |
| 553 | positions.append(index) |
| 554 | if positions != sorted(positions): |
| 555 | if contains_sensitive_expected_value: |
| 556 | return [f"{label} has wrong order in {context}"] |
| 557 | return [ |
| 558 | f"{label} has wrong order in {context}: expected " |
| 559 | + " before ".join(display_public_value(value) for value in expected_order) |
| 560 | ] |
| 561 | return [] |
| 562 | |
| 563 | |
| 564 | def provider_table_name(provider_id: str) -> str: |
| 565 | return SHARED_PROVIDER_TABLES.get(provider_id, provider_id.replace("-", "_")) |
| 566 | |
| 567 | |
| 568 | def main() -> int: |
| 569 | try: |
| 570 | config_rs = read(CONFIG_RS) |
| 571 | provider_kind_rs = read(PROVIDER_KIND_RS) |
| 572 | tui_config_rs = read(TUI_CONFIG_RS) |
| 573 | agent_rs = read(AGENT_RS) |
| 574 | providers_md = read(PROVIDERS_MD) |
| 575 | configuration_md = read(CONFIGURATION_MD) |
| 576 | web_facts_lib = read(WEB_FACTS_LIB) |
| 577 | web_facts_drift = read(WEB_FACTS_DRIFT) |
| 578 | web_facts_generated = read(WEB_FACTS_GENERATED) |
| 579 | readme_md = read(README_MD) |
| 580 | config_example_toml = read(CONFIG_EXAMPLE_TOML) |
| 581 | tui_provider_readiness_rs = read(TUI_PROVIDER_READINESS_RS) |
| 582 | tui_lib_rs = read(TUI_LIB_RS) |
| 583 | |
| 584 | variant_to_id = provider_kind_ids(config_rs) |
| 585 | canonical_ids = set(variant_to_id.values()) |
| 586 | selectable_provider_ids = provider_kind_catalog_ids( |
| 587 | provider_kind_rs, variant_to_id |
| 588 | ) |
| 589 | presentation_ids = presentation_provider_ids() |
| 590 | public_provider_ids = canonical_ids - LEGACY_PROVIDER_TOMBSTONE_IDS |
| 591 | expected_tables = { |
| 592 | provider_table_name(provider_id) for provider_id in public_provider_ids |
| 593 | } |
| 594 | runtime_tables = expected_tables | LEGACY_PROVIDER_TOMBSTONE_TABLES |
| 595 | |
| 596 | errors: list[str] = [] |
| 597 | errors += report_presentation_identity_drift(canonical_ids, presentation_ids) |
| 598 | errors += report_provider_kind_selector_contract(provider_kind_rs) |
| 599 | errors += report_tui_catalog_contract(tui_config_rs) |
| 600 | errors += report_tombstone_runtime_contract( |
| 601 | provider_kind_rs, tui_provider_readiness_rs, tui_lib_rs |
| 602 | ) |
| 603 | errors += report_set( |
| 604 | "legacy provider identities in ProviderKind::ALL", |
| 605 | set(), |
| 606 | selectable_provider_ids & LEGACY_PROVIDER_SELECTION_IDS, |
| 607 | ) |
| 608 | errors += report_set( |
| 609 | "documented selectable provider IDs", |
| 610 | selectable_provider_ids, |
| 611 | documented_selectable_provider_ids(providers_md), |
| 612 | ) |
| 613 | errors += report_huggingface_coverage( |
| 614 | config_rs, read(PROVIDER_RS), tui_config_rs, providers_md |
| 615 | ) |
| 616 | errors += report_antigravity_public_contract( |
| 617 | providers_md, |
| 618 | configuration_md, |
| 619 | web_facts_lib, |
| 620 | web_facts_drift, |
| 621 | web_facts_generated, |
| 622 | readme_md, |
| 623 | config_example_toml, |
| 624 | ) |
| 625 | errors += report_set( |
| 626 | "shipped provider rows", |
| 627 | public_provider_ids, |
| 628 | shipped_provider_rows(providers_md), |
| 629 | ) |
| 630 | errors += report_set( |
| 631 | "provider TOML tables", |
| 632 | runtime_tables, |
| 633 | provider_tables(config_rs) - META_PROVIDER_TABLES, |
| 634 | ) |
| 635 | errors += report_set( |
| 636 | "documented provider TOML tables", |
| 637 | expected_tables, |
| 638 | shipped_provider_tables(providers_md), |
| 639 | ) |
| 640 | errors += report_set( |
| 641 | "static ModelRegistry rows", |
| 642 | model_registry_providers(agent_rs, variant_to_id), |
| 643 | static_registry_provider_rows(providers_md), |
| 644 | ) |
| 645 | |
| 646 | missing_defaults = missing_default_strings(providers_md, default_strings(tui_config_rs)) |
| 647 | if missing_defaults: |
| 648 | errors.append( |
| 649 | "docs/PROVIDERS.md does not mention default strings as Markdown code spans: " |
| 650 | + ", ".join(missing_defaults) |
| 651 | ) |
| 652 | except ValueError as err: |
| 653 | errors = [str(err)] |
| 654 | |
| 655 | if errors: |
| 656 | print("Provider registry drift check failed:", file=sys.stderr) |
| 657 | for error in errors: |
| 658 | print(f"- {redact_sensitive_text(error)}", file=sys.stderr) |
| 659 | return 1 |
| 660 | |
| 661 | print("Provider registry drift check passed.") |
| 662 | return 0 |
| 663 | |
| 664 | |
| 665 | if __name__ == "__main__": |
| 666 | raise SystemExit(main()) |
| 667 |