返回 CodeWhale
check-provider-registry.py
根目录 / scripts / check-provider-registry.py
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
667 lines PYTHON