| 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 | - live TUI ApiProvider IDs |
| 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 re |
| 18 | import sys |
| 19 | from pathlib import Path |
| 20 | |
| 21 | |
| 22 | ROOT = Path(__file__).resolve().parents[1] |
| 23 | CONFIG_RS = ROOT / "crates" / "config" / "src" / "lib.rs" |
| 24 | # ProviderKind's enum + identity impl were split out of lib.rs into this module. |
| 25 | PROVIDER_KIND_RS = ROOT / "crates" / "config" / "src" / "provider_kind.rs" |
| 26 | PROVIDER_RS = ROOT / "crates" / "config" / "src" / "provider.rs" |
| 27 | TUI_CONFIG_RS = ROOT / "crates" / "tui" / "src" / "config.rs" |
| 28 | # Default provider model/base-URL constants were split out of config.rs into |
| 29 | # this leaf module (#3311); read them from there for the default-string check. |
| 30 | TUI_CONFIG_MODELS_RS = ROOT / "crates" / "tui" / "src" / "config" / "models.rs" |
| 31 | AGENT_RS = ROOT / "crates" / "agent" / "src" / "lib.rs" |
| 32 | PROVIDERS_MD = ROOT / "docs" / "PROVIDERS.md" |
| 33 | CONFIGURATION_MD = ROOT / "docs" / "CONFIGURATION.md" |
| 34 | WEB_FACTS_LIB = ROOT / "web" / "scripts" / "facts-lib.mjs" |
| 35 | WEB_FACTS_DRIFT = ROOT / "web" / "lib" / "facts-drift.ts" |
| 36 | WEB_FACTS_GENERATED = ROOT / "web" / "lib" / "facts.generated.ts" |
| 37 | README_MD = ROOT / "README.md" |
| 38 | CONFIG_EXAMPLE_TOML = ROOT / "config.example.toml" |
| 39 | TUI_PROVIDER_READINESS_RS = ROOT / "crates" / "tui" / "src" / "provider_readiness.rs" |
| 40 | TUI_LIB_RS = ROOT / "crates" / "tui" / "src" / "lib.rs" |
| 41 | |
| 42 | |
| 43 | API_PROVIDER_ONLY_IDS = {"deepseek-cn"} |
| 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 parse_aliases_for_variant(source: str, enum_name: str, variant: str, context: str) -> set[str]: |
| 116 | # `ProviderKind`'s enum + identity impl (incl. `parse`) live in |
| 117 | # provider_kind.rs after the config module split; read the impl from there |
| 118 | # regardless of the file the caller passed for other lookups. |
| 119 | if enum_name == "ProviderKind": |
| 120 | source = read(PROVIDER_KIND_RS) |
| 121 | context = "crates/config/src/provider_kind.rs" |
| 122 | impl_start = require_index(source, f"impl {enum_name}", context) |
| 123 | block = extract_match_block( |
| 124 | source, |
| 125 | "pub fn parse(value: &str) -> Option<Self>", |
| 126 | context, |
| 127 | impl_start, |
| 128 | ) |
| 129 | match_arm = re.search( |
| 130 | rf'((?:"[^"]+"\s*\|\s*)*"[^"]+")\s*=>\s*Some\(Self::{variant}\)', |
| 131 | block, |
| 132 | ) |
| 133 | if match_arm: |
| 134 | return set(re.findall(r'"([^"]+)"', match_arm.group(1))) |
| 135 | if enum_name in {"ProviderKind", "ApiProvider"}: |
| 136 | provider_rs = read(PROVIDER_RS) |
| 137 | provider_macro = re.search( |
| 138 | rf'provider!\(\s*\n\s*\w+,\s*\n\s*{variant},\s*\n\s*"([^"]+)".*?' |
| 139 | r"aliases:\s*\[(.*?)\]\s*\);", |
| 140 | provider_rs, |
| 141 | re.DOTALL, |
| 142 | ) |
| 143 | if provider_macro: |
| 144 | return {provider_macro.group(1)} | set( |
| 145 | re.findall(r'"([^"]+)"', provider_macro.group(2)) |
| 146 | ) |
| 147 | raise ValueError(f"{context}: missing parse arm for {variant}") |
| 148 | |
| 149 | |
| 150 | def provider_kind_ids(config_rs: str) -> dict[str, str]: |
| 151 | provider_rs = read(PROVIDER_RS) |
| 152 | pairs = re.findall( |
| 153 | r"provider!\(\s*\n\s*\w+,\s*\n\s*(\w+),\s*\n\s*\"([^\"]+)\"", |
| 154 | provider_rs, |
| 155 | ) |
| 156 | ids: dict[str, str] = {variant: provider_id for variant, provider_id in pairs} |
| 157 | # Providers with non-fixed wire policy or custom auth behavior use manual |
| 158 | # impls rather than the provider!() macro. Discover them by shape rather |
| 159 | # than by name: a hand-maintained roster here goes stale the first time |
| 160 | # someone adds a provider, which is exactly how this guard first failed |
| 161 | # (it had never heard of `concentrate`). |
| 162 | for variant_name, id_literal in re.findall( |
| 163 | r'impl\s+Provider\s+for\s+(\w+)\s*\{.*?fn\s+id\s*\([^)]*\)[^{]*\{\s*"([^"]+)"', |
| 164 | provider_rs, |
| 165 | flags=re.DOTALL, |
| 166 | ): |
| 167 | # `Custom` is a meta provider, not a shipped vendor row: it is |
| 168 | # handled by META_PROVIDER_TABLES and must stay out of the canonical |
| 169 | # id set, or the shipped-row and TOML-table checks contradict. |
| 170 | if variant_name == "Custom": |
| 171 | continue |
| 172 | ids.setdefault(variant_name, id_literal) |
| 173 | # Kept as an explicit floor: if the shape scan ever stops matching one of |
| 174 | # these, the guard should fail loudly rather than silently cover less. |
| 175 | for variant_name, id_literal in [ |
| 176 | ("Deepseek", "deepseek"), |
| 177 | ("DeepseekAnthropic", "deepseek-anthropic"), |
| 178 | ("OpenaiCodex", "openai-codex"), |
| 179 | ("Anthropic", "anthropic"), |
| 180 | ("Openmodel", "openmodel"), |
| 181 | ("MinimaxAnthropic", "minimax-anthropic"), |
| 182 | ("OpencodeZen", "opencode-zen"), |
| 183 | # Alibaba Model Studio ships four plan/dialect identities, each with a |
| 184 | # hand-written impl Provider for the same reason as the rows above: |
| 185 | # the wire policy is not fixed, so provider!() cannot express them. |
| 186 | ("ModelstudioTokenPlan", "modelstudio-token-plan"), |
| 187 | ("ModelstudioTokenPlanAnthropic", "modelstudio-token-plan-anthropic"), |
| 188 | ("ModelstudioCodingPlan", "modelstudio-coding-plan"), |
| 189 | ("ModelstudioCodingPlanAnthropic", "modelstudio-coding-plan-anthropic"), |
| 190 | ]: |
| 191 | match = re.search( |
| 192 | rf'impl\s+Provider\s+for\s+{variant_name}.*?fn\s+id.*?\"({id_literal})\"', |
| 193 | provider_rs, re.DOTALL, |
| 194 | ) |
| 195 | if match: |
| 196 | ids[variant_name] = match.group(1) |
| 197 | elif variant_name not in ids: |
| 198 | raise ValueError( |
| 199 | f"expected a hand-written `impl Provider for {variant_name}` " |
| 200 | f"with id {id_literal!r}; the guard's floor is stale" |
| 201 | ) |
| 202 | if not ids: |
| 203 | raise ValueError("provider!() invocations returned no providers") |
| 204 | return ids |
| 205 | |
| 206 | |
| 207 | def provider_kind_catalog_ids( |
| 208 | provider_kind_rs: str, variant_to_id: dict[str, str] |
| 209 | ) -> set[str]: |
| 210 | catalog = re.search( |
| 211 | r"pub const ALL:\s*\[Self;\s*\d+\]\s*=\s*\[(.*?)\];", |
| 212 | provider_kind_rs, |
| 213 | flags=re.DOTALL, |
| 214 | ) |
| 215 | if catalog is None: |
| 216 | raise ValueError("crates/config/src/provider_kind.rs: missing ProviderKind::ALL") |
| 217 | variants = set(re.findall(r"Self::(\w+)", catalog.group(1))) |
| 218 | catalog_variant_to_id = {**variant_to_id, "Custom": "custom"} |
| 219 | missing = variants - set(catalog_variant_to_id) |
| 220 | if missing: |
| 221 | raise ValueError(f"ProviderKind::ALL uses unknown variants: {sorted(missing)}") |
| 222 | return {catalog_variant_to_id[variant] for variant in variants} |
| 223 | |
| 224 | |
| 225 | def api_provider_ids(tui_config_rs: str) -> dict[str, str]: |
| 226 | # ApiProvider ids derive from ProviderKind ids (via delegation to .kind().as_str()) |
| 227 | # plus the legacy "deepseek-cn" variant that exists only in ApiProvider. |
| 228 | variant_to_id = provider_kind_ids("") |
| 229 | # ApiProvider::SiliconflowCn maps to ProviderKind::SiliconflowCN |
| 230 | if "SiliconflowCN" in variant_to_id: |
| 231 | variant_to_id["SiliconflowCn"] = variant_to_id["SiliconflowCN"] |
| 232 | variant_to_id["DeepseekCN"] = "deepseek-cn" |
| 233 | return variant_to_id |
| 234 | |
| 235 | |
| 236 | def provider_tables(config_rs: str) -> set[str]: |
| 237 | struct_start = require_index( |
| 238 | config_rs, "pub struct ProvidersToml", "crates/config/src/lib.rs" |
| 239 | ) |
| 240 | struct_end = require_index(config_rs, "\n}", "ProvidersToml struct", struct_start) |
| 241 | fields = re.findall( |
| 242 | r"pub\s+([a-z0-9_]+)\s*:\s*ProviderConfigToml", |
| 243 | config_rs[struct_start:struct_end], |
| 244 | ) |
| 245 | if not fields: |
| 246 | raise ValueError("ProvidersToml returned no provider tables") |
| 247 | return set(fields) |
| 248 | |
| 249 | |
| 250 | def shipped_provider_rows(providers_md: str) -> set[str]: |
| 251 | table = markdown_section(providers_md, "## Shipped Providers") |
| 252 | return set(re.findall(r"^\|\s*`([^`]+)`\s*\|", table, flags=re.MULTILINE)) |
| 253 | |
| 254 | |
| 255 | def shipped_provider_tables(providers_md: str) -> set[str]: |
| 256 | table = markdown_section(providers_md, "## Shipped Providers") |
| 257 | return set(re.findall(r"\|\s*`\[providers\.([a-z0-9_]+)\]`\s*\|", table)) |
| 258 | |
| 259 | |
| 260 | def documented_selectable_provider_ids(providers_md: str) -> set[str]: |
| 261 | marker = require_index(providers_md, "in that order:", "docs/PROVIDERS.md") |
| 262 | start = require_index(providers_md, "\n\n", "provider selection list", marker) + 2 |
| 263 | end = require_index(providers_md, "\n\n", "provider selection list", start) |
| 264 | return set(re.findall(r"`([^`]+)`", providers_md[start:end])) |
| 265 | |
| 266 | |
| 267 | def report_provider_kind_selector_contract(provider_kind_rs: str) -> list[str]: |
| 268 | start = require_index( |
| 269 | provider_kind_rs, |
| 270 | "pub fn parse(value: &str) -> Option<Self>", |
| 271 | "ProviderKind::parse", |
| 272 | ) |
| 273 | end = require_index( |
| 274 | provider_kind_rs, "pub fn parse_config_identity", "ProviderKind::parse", start |
| 275 | ) |
| 276 | selector = provider_kind_rs[start:end] |
| 277 | if "Self::ALL" not in selector and "Self::all()" not in selector: |
| 278 | return [ |
| 279 | "ProviderKind::parse must gate registry aliases through the selectable " |
| 280 | "ProviderKind::ALL catalog" |
| 281 | ] |
| 282 | return [] |
| 283 | |
| 284 | |
| 285 | def report_tui_catalog_contract(tui_config_rs: str) -> list[str]: |
| 286 | start = require_index( |
| 287 | tui_config_rs, "pub fn catalog() -> &'static [Self]", "ApiProvider::catalog" |
| 288 | ) |
| 289 | end = require_index( |
| 290 | tui_config_rs, "pub fn catalog_identity", "ApiProvider::catalog", start |
| 291 | ) |
| 292 | catalog = tui_config_rs[start:end] |
| 293 | errors: list[str] = [] |
| 294 | if ( |
| 295 | "codewhale_config::ProviderKind::ALL" not in catalog |
| 296 | or "Antigravity" in catalog |
| 297 | ): |
| 298 | errors.append( |
| 299 | "ApiProvider::catalog must derive from ProviderKind::ALL without " |
| 300 | "legacy Antigravity" |
| 301 | ) |
| 302 | |
| 303 | impl_start = require_index(tui_config_rs, "impl ApiProvider", "ApiProvider impl") |
| 304 | parse_start = require_index( |
| 305 | tui_config_rs, |
| 306 | "pub fn parse(value: &str) -> Option<Self>", |
| 307 | "ApiProvider::parse", |
| 308 | impl_start, |
| 309 | ) |
| 310 | parse_end = require_index( |
| 311 | tui_config_rs, "pub fn as_str", "ApiProvider::parse", parse_start |
| 312 | ) |
| 313 | selector = tui_config_rs[parse_start:parse_end] |
| 314 | if ( |
| 315 | "is_legacy_antigravity_identity(trimmed)" not in selector |
| 316 | or "return None" not in selector |
| 317 | ): |
| 318 | errors.append( |
| 319 | "ApiProvider::parse must reject both retired Antigravity config identities" |
| 320 | ) |
| 321 | return errors |
| 322 | |
| 323 | |
| 324 | def report_tombstone_runtime_contract( |
| 325 | provider_kind_rs: str, tui_provider_readiness_rs: str, tui_lib_rs: str |
| 326 | ) -> list[str]: |
| 327 | """The tombstone must resolve under every legacy spelling and never read |
| 328 | as a credentialed or advertised slot on a running-product surface.""" |
| 329 | |
| 330 | errors: list[str] = [] |
| 331 | start = require_index( |
| 332 | provider_kind_rs, |
| 333 | "pub fn parse_config_identity(value: &str) -> Option<Self>", |
| 334 | "ProviderKind::parse_config_identity", |
| 335 | ) |
| 336 | end = require_index( |
| 337 | provider_kind_rs, "pub fn secret_store_slot", "ProviderKind::parse_config_identity", start |
| 338 | ) |
| 339 | config_identity = provider_kind_rs[start:end] |
| 340 | if "parse_retired_alias" not in config_identity: |
| 341 | errors.append( |
| 342 | "ProviderKind::parse_config_identity must resolve retired registry aliases " |
| 343 | "(`agy`) so every selection surface can name the tombstone" |
| 344 | ) |
| 345 | |
| 346 | if ( |
| 347 | "provider == ApiProvider::Antigravity || provider.kind().is_none()" |
| 348 | not in tui_provider_readiness_rs |
| 349 | ): |
| 350 | errors.append( |
| 351 | "provider_readiness::credential_state_for_provider must classify " |
| 352 | "ApiProvider::Antigravity as CredentialState::Legacy" |
| 353 | ) |
| 354 | |
| 355 | if "for provider in doctor_api_key_providers()" not in tui_lib_rs or ( |
| 356 | "*provider != crate::config::ApiProvider::Antigravity" not in tui_lib_rs |
| 357 | ): |
| 358 | errors.append( |
| 359 | "`codewhale doctor` API Keys rows must iterate doctor_api_key_providers() " |
| 360 | "with the retired Antigravity slot filtered out" |
| 361 | ) |
| 362 | return errors |
| 363 | |
| 364 | |
| 365 | def report_antigravity_public_contract( |
| 366 | providers_md: str, |
| 367 | configuration_md: str, |
| 368 | web_facts_lib: str, |
| 369 | web_facts_drift: str, |
| 370 | web_facts_generated: str, |
| 371 | readme_md: str, |
| 372 | config_example_toml: str, |
| 373 | ) -> list[str]: |
| 374 | """Keep the retired provider as one safe, non-runnable docs tombstone.""" |
| 375 | |
| 376 | errors: list[str] = [] |
| 377 | heading = "### Legacy Antigravity tombstone" |
| 378 | heading_count = providers_md.count(heading) |
| 379 | if heading_count != 1: |
| 380 | errors.append( |
| 381 | "docs/PROVIDERS.md must contain exactly one legacy Antigravity tombstone " |
| 382 | f"heading (found {heading_count})" |
| 383 | ) |
| 384 | tombstone = "" |
| 385 | outside_tombstone = providers_md |
| 386 | else: |
| 387 | start = providers_md.index(heading) |
| 388 | next_heading = re.search(r"\n#{1,3} ", providers_md[start + len(heading) :]) |
| 389 | end = ( |
| 390 | len(providers_md) |
| 391 | if next_heading is None |
| 392 | else start + len(heading) + next_heading.start() |
| 393 | ) |
| 394 | tombstone = providers_md[start:end] |
| 395 | outside_tombstone = providers_md[:start] + providers_md[end:] |
| 396 | |
| 397 | normalized_tombstone = " ".join(tombstone.split()) |
| 398 | required_tombstone_copy = [ |
| 399 | "not a Codewhale provider", |
| 400 | "cannot be selected or run", |
| 401 | "non-runnable migration tombstone", |
| 402 | "`codewhale auth clear --provider antigravity`", |
| 403 | "Codewhale-owned legacy configuration and consent metadata", |
| 404 | "does not sign out of, revoke, read, or otherwise alter any official Google or Antigravity session", |
| 405 | "supported `google` provider", |
| 406 | "`GEMINI_API_KEY`", |
| 407 | ] |
| 408 | missing_tombstone_copy = [ |
| 409 | required |
| 410 | for required in required_tombstone_copy |
| 411 | if required not in normalized_tombstone |
| 412 | ] |
| 413 | if missing_tombstone_copy: |
| 414 | errors.append( |
| 415 | "legacy Antigravity tombstone is missing required safety or migration copy " |
| 416 | f"({len(missing_tombstone_copy)} checks failed)" |
| 417 | ) |
| 418 | clear_command = "`codewhale auth clear --provider antigravity`" |
| 419 | legacy_provider_forms = [ |
| 420 | match.lower() |
| 421 | for match in re.findall( |
| 422 | r"--provider\s+(antigravity|agy)\b", providers_md, flags=re.IGNORECASE |
| 423 | ) |
| 424 | ] |
| 425 | if providers_md.count(clear_command) != 1 or legacy_provider_forms != [ |
| 426 | "antigravity" |
| 427 | ]: |
| 428 | errors.append( |
| 429 | "docs/PROVIDERS.md must contain the Codewhale-owned Antigravity " |
| 430 | "clear command as its only --provider antigravity/agy form" |
| 431 | ) |
| 432 | setup_guidance = re.search( |
| 433 | r"\bagy\b|\boauth\b|\blog(?:in|\s+in)\b|\bsign\s+in\b|" |
| 434 | r"\bimport\b|\bexternal-consent\b|/provider\s+(?:antigravity|agy)\b|" |
| 435 | r"CODEWHALE_PROVIDER\s*=\s*(?:antigravity|agy)\b", |
| 436 | tombstone, |
| 437 | flags=re.IGNORECASE, |
| 438 | ) |
| 439 | if setup_guidance: |
| 440 | errors.append( |
| 441 | "legacy Antigravity tombstone contains login, OAuth import, consent, " |
| 442 | "or provider-selection guidance" |
| 443 | ) |
| 444 | |
| 445 | if re.search(r"\b(?:antigravity|agy)\b", outside_tombstone, flags=re.IGNORECASE): |
| 446 | errors.append( |
| 447 | "docs/PROVIDERS.md mentions Antigravity/agy outside its legacy tombstone" |
| 448 | ) |
| 449 | if re.search(r"\b(?:antigravity|agy)\b", configuration_md, flags=re.IGNORECASE): |
| 450 | errors.append("docs/CONFIGURATION.md advertises retired Antigravity state") |
| 451 | if re.search(r"\b(?:antigravity|agy)\b", readme_md, flags=re.IGNORECASE): |
| 452 | errors.append("README.md advertises retired Antigravity state") |
| 453 | if re.search(r"\b(?:antigravity|agy)\b", config_example_toml, flags=re.IGNORECASE): |
| 454 | errors.append("config.example.toml advertises retired Antigravity state") |
| 455 | if "[providers.google]" not in config_example_toml or not re.search( |
| 456 | r"GEMINI_API_KEY", config_example_toml |
| 457 | ): |
| 458 | errors.append( |
| 459 | "config.example.toml must document the supported `google` Gemini route " |
| 460 | "with GEMINI_API_KEY" |
| 461 | ) |
| 462 | |
| 463 | forbidden_markers = { |
| 464 | "Antigravity API-key environment guidance": "ANTIGRAVITY_API_KEY", |
| 465 | "Antigravity ADC environment guidance": "AGY_ADC_AUTH", |
| 466 | "Antigravity base-URL environment guidance": "ANTIGRAVITY_BASE_URL", |
| 467 | "Antigravity model environment guidance": "ANTIGRAVITY_MODEL", |
| 468 | "private cloud-code endpoint guidance": "cloudcode-pa", |
| 469 | "private cloud-code protocol guidance": "cloud-code", |
| 470 | "official CLI credential-store guidance": "state.vscdb", |
| 471 | "official CLI OAuth-state guidance": "antigravityUnifiedStateSync", |
| 472 | "runnable legacy provider selection": 'provider = "antigravity"', |
| 473 | "runnable legacy provider table": "[providers.antigravity]", |
| 474 | } |
| 475 | public_sources = { |
| 476 | "docs/PROVIDERS.md": providers_md, |
| 477 | "docs/CONFIGURATION.md": configuration_md, |
| 478 | "web/scripts/facts-lib.mjs": web_facts_lib, |
| 479 | "web/lib/facts-drift.ts": web_facts_drift, |
| 480 | "web/lib/facts.generated.ts": web_facts_generated, |
| 481 | "README.md": readme_md, |
| 482 | "config.example.toml": config_example_toml, |
| 483 | } |
| 484 | for context, source in public_sources.items(): |
| 485 | for description, marker in forbidden_markers.items(): |
| 486 | if marker.lower() in source.lower(): |
| 487 | errors.append(f"{context} contains forbidden {description}") |
| 488 | |
| 489 | for context, source, exclusion_name, exclusion_filter in [ |
| 490 | ( |
| 491 | "web/scripts/facts-lib.mjs", |
| 492 | web_facts_lib, |
| 493 | "EXCLUDED_PROVIDERS", |
| 494 | ".filter((v) => !EXCLUDED_PROVIDERS.has(v))", |
| 495 | ), |
| 496 | ( |
| 497 | "web/lib/facts-drift.ts", |
| 498 | web_facts_drift, |
| 499 | "EXCLUDED", |
| 500 | ".filter((v) => !EXCLUDED.has(v))", |
| 501 | ), |
| 502 | ]: |
| 503 | exclusion_decl = re.search( |
| 504 | rf"const\s+{exclusion_name}\s*=\s*new Set\(\[[^\]]*\"Antigravity\"", |
| 505 | source, |
| 506 | ) |
| 507 | if exclusion_decl is None or exclusion_filter not in source: |
| 508 | errors.append(f"{context} does not explicitly exclude legacy Antigravity") |
| 509 | if re.search(r"^\s*Antigravity\s*:", source, flags=re.MULTILINE): |
| 510 | errors.append(f"{context} maps legacy Antigravity to public provider facts") |
| 511 | if re.search(r"\bagy\b", source, flags=re.IGNORECASE): |
| 512 | errors.append(f"{context} exposes the legacy agy alias") |
| 513 | |
| 514 | if re.search( |
| 515 | r"\b(?:antigravity|agy)\b", web_facts_generated, flags=re.IGNORECASE |
| 516 | ): |
| 517 | errors.append("web/lib/facts.generated.ts exposes legacy Antigravity/agy") |
| 518 | |
| 519 | return errors |
| 520 | |
| 521 | |
| 522 | def static_registry_provider_rows(providers_md: str) -> set[str]: |
| 523 | table = markdown_section(providers_md, "## Static Model Registry") |
| 524 | return set(re.findall(r"^\|\s*`([^`]+)`\s*\|", table, flags=re.MULTILINE)) |
| 525 | |
| 526 | |
| 527 | def model_registry_providers(agent_rs: str, variant_to_id: dict[str, str]) -> set[str]: |
| 528 | variants = set(re.findall(r"provider:\s*ProviderKind::(\w+)", agent_rs)) |
| 529 | missing = variants - set(variant_to_id) |
| 530 | if missing: |
| 531 | raise ValueError(f"ModelRegistry uses unknown provider variants: {sorted(missing)}") |
| 532 | return {variant_to_id[variant] for variant in variants} |
| 533 | |
| 534 | |
| 535 | def default_strings(tui_config_rs: str) -> set[str]: |
| 536 | # Model/base-URL constants now live in config/models.rs (#3311); scan it |
| 537 | # alongside config.rs so the check follows the leaf split. |
| 538 | sources = tui_config_rs + "\n" + read(TUI_CONFIG_MODELS_RS) |
| 539 | defaults = set() |
| 540 | for name, value in re.findall( |
| 541 | r'const\s+(DEFAULT_[A-Z0-9_]+(?:MODEL|BASE_URL)):\s*&str\s*=\s*"([^"]+)"', |
| 542 | sources, |
| 543 | ): |
| 544 | if name == "DEFAULT_DEEPSEEKCN_BASE_URL" or name.startswith( |
| 545 | "DEFAULT_ANTIGRAVITY_" |
| 546 | ): |
| 547 | continue |
| 548 | defaults.add(value) |
| 549 | if not defaults: |
| 550 | raise ValueError("no default provider model/base URL constants found") |
| 551 | return defaults |
| 552 | |
| 553 | |
| 554 | def missing_default_strings(providers_md: str, defaults: set[str]) -> list[str]: |
| 555 | # Inline-code validation should not let fenced TOML/bash examples pair a |
| 556 | # stray backtick with later prose; strip fenced blocks before scanning. |
| 557 | inline_source = re.sub(r"```.*?```", "", providers_md, flags=re.DOTALL) |
| 558 | code_spans = set(re.findall(r"`([^`]+)`", inline_source)) |
| 559 | return sorted(defaults - code_spans) |
| 560 | |
| 561 | |
| 562 | def report_set(label: str, expected: set[str], actual: set[str]) -> list[str]: |
| 563 | errors = [] |
| 564 | missing = sorted(expected - actual) |
| 565 | extra = sorted(actual - expected) |
| 566 | if missing: |
| 567 | errors.append(f"{label} missing: {', '.join(missing)}") |
| 568 | if extra: |
| 569 | errors.append(f"{label} extra: {', '.join(extra)}") |
| 570 | return errors |
| 571 | |
| 572 | |
| 573 | def report_provider_enum_drift( |
| 574 | provider_kind_ids: set[str], api_provider_ids: set[str] |
| 575 | ) -> list[str]: |
| 576 | errors = [] |
| 577 | missing_from_api_provider = sorted(provider_kind_ids - api_provider_ids) |
| 578 | unexpected_api_provider_ids = sorted( |
| 579 | api_provider_ids - provider_kind_ids - API_PROVIDER_ONLY_IDS |
| 580 | ) |
| 581 | missing_allowlisted_ids = sorted(API_PROVIDER_ONLY_IDS - api_provider_ids) |
| 582 | |
| 583 | if missing_from_api_provider: |
| 584 | errors.append( |
| 585 | "ApiProvider missing ProviderKind IDs: " |
| 586 | + ", ".join(missing_from_api_provider) |
| 587 | ) |
| 588 | if unexpected_api_provider_ids: |
| 589 | errors.append( |
| 590 | "ApiProvider has non-whitelisted IDs absent from ProviderKind: " |
| 591 | + ", ".join(unexpected_api_provider_ids) |
| 592 | ) |
| 593 | if missing_allowlisted_ids: |
| 594 | errors.append( |
| 595 | "ApiProvider-only whitelist entries are absent from ApiProvider: " |
| 596 | + ", ".join(missing_allowlisted_ids) |
| 597 | ) |
| 598 | return errors |
| 599 | |
| 600 | |
| 601 | def report_huggingface_coverage( |
| 602 | config_rs: str, tui_config_rs: str, providers_md: str |
| 603 | ) -> list[str]: |
| 604 | errors = [] |
| 605 | |
| 606 | config_aliases = parse_aliases_for_variant( |
| 607 | config_rs, "ProviderKind", "Huggingface", "crates/config/src/lib.rs" |
| 608 | ) |
| 609 | tui_aliases = parse_aliases_for_variant( |
| 610 | tui_config_rs, "ApiProvider", "Huggingface", "crates/tui/src/config.rs" |
| 611 | ) |
| 612 | errors += report_set( |
| 613 | "ProviderKind Hugging Face aliases", |
| 614 | HUGGINGFACE_ALIASES, |
| 615 | config_aliases & HUGGINGFACE_ALIASES, |
| 616 | ) |
| 617 | errors += report_set( |
| 618 | "ApiProvider Hugging Face aliases", |
| 619 | HUGGINGFACE_ALIASES, |
| 620 | tui_aliases & HUGGINGFACE_ALIASES, |
| 621 | ) |
| 622 | |
| 623 | inline_source = re.sub(r"```.*?```", "", providers_md, flags=re.DOTALL) |
| 624 | code_spans = set(re.findall(r"`([^`]+)`", inline_source)) |
| 625 | errors += report_set( |
| 626 | "documented Hugging Face aliases", |
| 627 | HUGGINGFACE_ALIASES, |
| 628 | code_spans & HUGGINGFACE_ALIASES, |
| 629 | ) |
| 630 | |
| 631 | for label, env_order in [ |
| 632 | ("Hugging Face auth env precedence", HUGGINGFACE_API_KEY_ENV_ORDER), |
| 633 | ("Hugging Face base URL env precedence", HUGGINGFACE_BASE_URL_ENV_ORDER), |
| 634 | ("Hugging Face model env precedence", HUGGINGFACE_MODEL_ENV_ORDER), |
| 635 | ]: |
| 636 | errors += report_env_lookup_order( |
| 637 | label, config_rs, env_order, "crates/config/src/lib.rs" |
| 638 | ) |
| 639 | errors += report_env_lookup_order( |
| 640 | label, tui_config_rs, env_order, "crates/tui/src/config.rs" |
| 641 | ) |
| 642 | errors += report_string_order(label, providers_md, env_order, "docs/PROVIDERS.md") |
| 643 | |
| 644 | return errors |
| 645 | |
| 646 | |
| 647 | def report_env_lookup_order( |
| 648 | label: str, source: str, expected_order: list[str], context: str |
| 649 | ) -> list[str]: |
| 650 | lookup_needles = [f'std::env::var("{name}")' for name in expected_order] |
| 651 | return report_string_order(label, source, lookup_needles, context) |
| 652 | |
| 653 | |
| 654 | def report_string_order( |
| 655 | label: str, source: str, expected_order: list[str], context: str |
| 656 | ) -> list[str]: |
| 657 | contains_sensitive_expected_value = any( |
| 658 | SENSITIVE_IDENTIFIER_RE.search(value) for value in expected_order |
| 659 | ) |
| 660 | positions = [] |
| 661 | for needle in expected_order: |
| 662 | index = source.find(needle) |
| 663 | if index == -1: |
| 664 | if contains_sensitive_expected_value: |
| 665 | return [f"{label} missing required entry in {context}"] |
| 666 | return [f"{label} missing {display_public_value(needle)!r} in {context}"] |
| 667 | positions.append(index) |
| 668 | if positions != sorted(positions): |
| 669 | if contains_sensitive_expected_value: |
| 670 | return [f"{label} has wrong order in {context}"] |
| 671 | return [ |
| 672 | f"{label} has wrong order in {context}: expected " |
| 673 | + " before ".join(display_public_value(value) for value in expected_order) |
| 674 | ] |
| 675 | return [] |
| 676 | |
| 677 | |
| 678 | def provider_table_name(provider_id: str) -> str: |
| 679 | return SHARED_PROVIDER_TABLES.get(provider_id, provider_id.replace("-", "_")) |
| 680 | |
| 681 | |
| 682 | def main() -> int: |
| 683 | try: |
| 684 | config_rs = read(CONFIG_RS) |
| 685 | provider_kind_rs = read(PROVIDER_KIND_RS) |
| 686 | tui_config_rs = read(TUI_CONFIG_RS) |
| 687 | agent_rs = read(AGENT_RS) |
| 688 | providers_md = read(PROVIDERS_MD) |
| 689 | configuration_md = read(CONFIGURATION_MD) |
| 690 | web_facts_lib = read(WEB_FACTS_LIB) |
| 691 | web_facts_drift = read(WEB_FACTS_DRIFT) |
| 692 | web_facts_generated = read(WEB_FACTS_GENERATED) |
| 693 | readme_md = read(README_MD) |
| 694 | config_example_toml = read(CONFIG_EXAMPLE_TOML) |
| 695 | tui_provider_readiness_rs = read(TUI_PROVIDER_READINESS_RS) |
| 696 | tui_lib_rs = read(TUI_LIB_RS) |
| 697 | |
| 698 | variant_to_id = provider_kind_ids(config_rs) |
| 699 | canonical_ids = set(variant_to_id.values()) |
| 700 | selectable_provider_ids = provider_kind_catalog_ids( |
| 701 | provider_kind_rs, variant_to_id |
| 702 | ) |
| 703 | live_api_provider_ids = set(api_provider_ids(tui_config_rs).values()) |
| 704 | public_provider_ids = canonical_ids - LEGACY_PROVIDER_TOMBSTONE_IDS |
| 705 | expected_tables = { |
| 706 | provider_table_name(provider_id) for provider_id in public_provider_ids |
| 707 | } |
| 708 | runtime_tables = expected_tables | LEGACY_PROVIDER_TOMBSTONE_TABLES |
| 709 | |
| 710 | errors: list[str] = [] |
| 711 | errors += report_provider_enum_drift(canonical_ids, live_api_provider_ids) |
| 712 | errors += report_provider_kind_selector_contract(provider_kind_rs) |
| 713 | errors += report_tui_catalog_contract(tui_config_rs) |
| 714 | errors += report_tombstone_runtime_contract( |
| 715 | provider_kind_rs, tui_provider_readiness_rs, tui_lib_rs |
| 716 | ) |
| 717 | errors += report_set( |
| 718 | "legacy provider identities in ProviderKind::ALL", |
| 719 | set(), |
| 720 | selectable_provider_ids & LEGACY_PROVIDER_SELECTION_IDS, |
| 721 | ) |
| 722 | errors += report_set( |
| 723 | "documented selectable provider IDs", |
| 724 | selectable_provider_ids, |
| 725 | documented_selectable_provider_ids(providers_md), |
| 726 | ) |
| 727 | errors += report_huggingface_coverage(config_rs, tui_config_rs, providers_md) |
| 728 | errors += report_antigravity_public_contract( |
| 729 | providers_md, |
| 730 | configuration_md, |
| 731 | web_facts_lib, |
| 732 | web_facts_drift, |
| 733 | web_facts_generated, |
| 734 | readme_md, |
| 735 | config_example_toml, |
| 736 | ) |
| 737 | errors += report_set( |
| 738 | "shipped provider rows", |
| 739 | public_provider_ids, |
| 740 | shipped_provider_rows(providers_md), |
| 741 | ) |
| 742 | errors += report_set( |
| 743 | "provider TOML tables", |
| 744 | runtime_tables, |
| 745 | provider_tables(config_rs) - META_PROVIDER_TABLES, |
| 746 | ) |
| 747 | errors += report_set( |
| 748 | "documented provider TOML tables", |
| 749 | expected_tables, |
| 750 | shipped_provider_tables(providers_md), |
| 751 | ) |
| 752 | errors += report_set( |
| 753 | "static ModelRegistry rows", |
| 754 | model_registry_providers(agent_rs, variant_to_id), |
| 755 | static_registry_provider_rows(providers_md), |
| 756 | ) |
| 757 | |
| 758 | missing_defaults = missing_default_strings(providers_md, default_strings(tui_config_rs)) |
| 759 | if missing_defaults: |
| 760 | errors.append( |
| 761 | "docs/PROVIDERS.md does not mention default strings as Markdown code spans: " |
| 762 | + ", ".join(missing_defaults) |
| 763 | ) |
| 764 | except ValueError as err: |
| 765 | errors = [str(err)] |
| 766 | |
| 767 | if errors: |
| 768 | print("Provider registry drift check failed:", file=sys.stderr) |
| 769 | for error in errors: |
| 770 | print(f"- {redact_sensitive_text(error)}", file=sys.stderr) |
| 771 | return 1 |
| 772 | |
| 773 | print("Provider registry drift check passed.") |
| 774 | return 0 |
| 775 | |
| 776 | |
| 777 | if __name__ == "__main__": |
| 778 | raise SystemExit(main()) |
| 779 |