"""CLI entry point for the GraphQL schema audit.""" import argparse import json import os import sys from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime, timedelta, timezone from typing import Any from src.config import DEFAULT_VARIANT, GRAPH_ID from src.graphos_client import fetch_usage_for_type from src.report import find_dead_fields, find_dead_objects, write_report from src.rover import fetch_subgraph_sdl, list_subgraphs from src.schema_parser_gql import parse_sdl def save_usage_data(path: str, usage_cache: dict[str, dict[str, dict[str, int]] | None], since_str: str, months: int) -> None: """Persist GraphOS usage data to disk.""" payload = { 'generated_at': datetime.now(timezone.utc).isoformat(), 'since': since_str, 'months': months, 'usage': usage_cache, } with open(path, 'w') as fp: json.dump(payload, fp, indent=2) print(f'Usage data written to {path}') def load_usage_data(path: str) -> tuple[dict[str, dict[str, dict[str, int]] | None], str, int]: """Load persisted usage data from disk.""" with open(path) as fp: data = json.load(fp) print(f'Loaded usage data from {path} (generated {data["generated_at"]})') return data['usage'], data['since'], data['months'] def load_schema_data(path: str) -> tuple[list[str], dict[str, str], dict[str, str], dict[str, dict[str, Any]]]: """Load persisted schema data from disk.""" with open(path) as fp: data = json.load(fp) subgraphs = data['subgraphs'] all_queries = data['all_queries'] all_mutations = data['all_mutations'] all_objects = {k: {'fields': set(v['fields']), 'repo': v['subgraph']} for k, v in data['all_objects'].items()} print(f'Loaded schema data from {path} (generated {data["generated_at"]})') return subgraphs, all_queries, all_mutations, all_objects def save_schema_data( path: str, graph_ref: str, subgraphs: list[str], all_queries: dict[str, str], all_mutations: dict[str, str], all_objects: dict[str, dict[str, Any]], ) -> None: """Persist parsed schema definitions to disk.""" payload = { 'generated_at': datetime.now(timezone.utc).isoformat(), 'graph_ref': graph_ref, 'subgraphs': subgraphs, 'all_queries': all_queries, 'all_mutations': all_mutations, 'all_objects': {k: {'fields': sorted(v['fields']), 'subgraph': v['repo']} for k, v in sorted(all_objects.items())}, } with open(path, 'w') as fp: json.dump(payload, fp, indent=2) print(f'\nSchema data written to {path}') def main() -> None: parser = argparse.ArgumentParser(description='Audit GraphQL schema fields across all subgraph repos.') parser.add_argument('--months', type=int, default=3, help='Lookback period in months (default: 3)') parser.add_argument('--graph-id', default=GRAPH_ID, help=f'Graph ID (default: {GRAPH_ID})') parser.add_argument('--variant', default=DEFAULT_VARIANT, help=f'Graph variant (default: {DEFAULT_VARIANT})') parser.add_argument('--output', default=os.path.join(os.path.dirname(__file__), '..', 'audit-data.json'), help='Output JSON file (default: backend/audit-data.json)') parser.add_argument('--schema-data', default=os.path.join(os.path.dirname(__file__), '..', 'schema-data.json'), help='Schema data JSON file (default: backend/schema-data.json)') parser.add_argument('--workers', type=int, default=10, help='Number of concurrent requests (default: 10)') parser.add_argument('--dry-run', action='store_true', help='Parse schemas and print collected fields without querying GraphOS') parser.add_argument('--fetch-only', action='store_true', help='Fetch and parse schemas, write schema-data.json, then stop') parser.add_argument('--usage-data', default=os.path.join(os.path.dirname(__file__), '..', 'usage-data.json'), help='Usage data JSON file (default: backend/usage-data.json)') parser.add_argument('--from-cache', action='store_true', help='Load schema + usage data from disk and re-run analysis (no fetching)') args = parser.parse_args() graph_ref = f'{args.graph_id}@{args.variant}' usage_data_path = os.path.abspath(args.usage_data) all_queries: dict[str, str] = {} all_mutations: dict[str, str] = {} all_objects: dict[str, dict[str, Any]] = {} usage_cache: dict[str, dict[str, dict[str, int]] | None] = {} subgraphs: list[str] = [] since_str = '' months = args.months if args.from_cache: # Load everything from disk schema_data_path = os.path.abspath(args.schema_data) for path, label in [(schema_data_path, 'schema-data.json'), (usage_data_path, 'usage-data.json')]: if not os.path.exists(path): print(f'Error: {label} not found at {path}', file=sys.stderr) sys.exit(1) subgraphs, all_queries, all_mutations, all_objects = load_schema_data(schema_data_path) usage_cache, since_str, months = load_usage_data(usage_data_path) else: # 1. List subgraphs via rover print(f'Listing subgraphs for {graph_ref} ...') subgraphs = list_subgraphs(graph_ref) if not subgraphs: print('Error: no subgraphs found', file=sys.stderr) sys.exit(1) print(f'Found {len(subgraphs)} subgraphs: {", ".join(subgraphs)}\n') # 2. Fetch SDL and parse each subgraph for name in subgraphs: print(f' Fetching {name} ...') sdl = fetch_subgraph_sdl(graph_ref, name) queries, mutations, objects = parse_sdl(sdl) for q in queries: all_queries[q] = name for m in mutations: all_mutations[m] = name for type_name, fields in objects.items(): all_objects[type_name] = {'fields': fields, 'repo': name} # Persist schema definitions schema_data_path = os.path.abspath(args.schema_data) save_schema_data(schema_data_path, graph_ref, subgraphs, all_queries, all_mutations, all_objects) if args.fetch_only: return if args.dry_run: print('\n--- DRY RUN: Queries ---') for name in sorted(all_queries): print(f' Query.{name} ({all_queries[name]})') print('\n--- DRY RUN: Mutations ---') for name in sorted(all_mutations): print(f' Mutation.{name} ({all_mutations[name]})') print('\n--- DRY RUN: Object Types ---') for type_name in sorted(all_objects): info = all_objects[type_name] print(f' {type_name} ({info["repo"]}, {len(info["fields"])} fields)') return api_key = os.environ.get('APOLLO_KEY') if not api_key: print('Error: APOLLO_KEY environment variable not set', file=sys.stderr) sys.exit(1) # 3. Query GraphOS for usage since = datetime.now(timezone.utc) - timedelta(days=months * 30) since_str = since.strftime('%Y-%m-%dT00:00:00Z') print(f'\nQuerying GraphOS usage since {since_str} ({months} months) ...\n') types_to_query: set[str] = set() types_to_query.add('Query') types_to_query.add('Mutation') types_to_query.update(all_objects.keys()) type_list = sorted(types_to_query) errors = 0 print(f' Fetching usage for {len(type_list)} types ({args.workers} workers) ...\n') with ThreadPoolExecutor(max_workers=args.workers) as pool: futures = {pool.submit(fetch_usage_for_type, api_key, args.graph_id, since_str, t): t for t in type_list} for i, future in enumerate(as_completed(futures), 1): type_name = futures[future] try: usage = future.result() except Exception as e: usage = None print(f' [{i}/{len(type_list)}] {type_name} ... EXCEPTION: {e}') else: if usage is None: errors += 1 print(f' [{i}/{len(type_list)}] {type_name} ... ERROR') else: print(f' [{i}/{len(type_list)}] {type_name} ... {len(usage)} fields') usage_cache[type_name] = usage if errors: print(f'\n {errors} types failed.') # Persist usage data save_usage_data(usage_data_path, usage_cache, since_str, months) total_queries = len(all_queries) total_mutations = len(all_mutations) total_object_fields = sum(len(v['fields']) for v in all_objects.values()) print(f'\n Queries: {total_queries}') print(f' Mutations: {total_mutations}') print(f' Object types: {len(all_objects)} ({total_object_fields} fields)') # 4. Identify dead fields and dead objects dead_queries, dead_mutations, dead_object_fields = find_dead_fields( all_queries, all_mutations, all_objects, usage_cache, args.graph_id, months ) dead_objects = find_dead_objects(all_objects, dead_object_fields, args.graph_id) # 5. Print summary print(f'\n{"=" * 70}') print(f'DEAD FIELD SUMMARY (since {since_str})') print(f'{"=" * 70}') print(f' Dead queries: {len(dead_queries)} / {total_queries}') print(f' Dead mutations: {len(dead_mutations)} / {total_mutations}') print(f' Dead object fields: {len(dead_object_fields)} / {total_object_fields}') print(f' Dead objects: {len(dead_objects)} / {len(all_objects)}') # 6. Write JSON output_path = os.path.abspath(args.output) totals = { 'queries': total_queries, 'mutations': total_mutations, 'object_fields': total_object_fields, 'objects': len(all_objects), } write_report( output_path, since_str, months, args.graph_id, subgraphs, totals, dead_queries, dead_mutations, dead_object_fields, dead_objects, ) print('Open ../frontend/dashboard.html to browse results.') if __name__ == '__main__': main()