#!/usr/bin/env python3 """ StellaOps Solution Generator. Generates consistent .sln files for: - Main solution (src/StellaOps.sln) with all projects - Module solutions (src//StellaOps..sln) with external deps in __External/ Usage: python sln_generator.py [OPTIONS] Options: --src-root PATH Root of src/ directory (default: ./src) --main-only Only regenerate main solution --module NAME Regenerate specific module solution only --all Regenerate all solutions (default) --dry-run Show changes without writing --check CI mode: exit 1 if solutions need updating -v, --verbose Verbose output """ import argparse import logging import sys from pathlib import Path from lib.csproj_parser import find_all_csproj, parse_csproj from lib.dependency_graph import ( collect_all_external_dependencies, get_module_projects, ) from lib.models import CsprojProject from lib.sln_writer import ( build_external_folder_hierarchy, build_folder_hierarchy, generate_solution_content, has_bypass_marker, write_solution_file, ) logger = logging.getLogger(__name__) # Directories under src/ that are modules (have their own solutions) # Excludes special directories like __Libraries, __Tests, __Analyzers, Web, etc. EXCLUDED_FROM_MODULE_SOLUTIONS = { "__Libraries", "__Tests", "__Analyzers", ".nuget", ".cache", ".vs", "Web", # Angular project, not .NET "plugins", "app", "Api", "Sdk", "DevPortal", "Mirror", "Provenance", "Symbols", "Unknowns", } def setup_logging(verbose: bool) -> None: """Configure logging based on verbosity.""" level = logging.DEBUG if verbose else logging.INFO logging.basicConfig( level=level, format="%(levelname)s: %(message)s", ) def discover_modules(src_root: Path) -> list[Path]: """ Discover all module directories under src/. A module is a directory that: - Is a direct child of src/ - Is not in EXCLUDED_FROM_MODULE_SOLUTIONS - Contains at least one .csproj file Returns: List of absolute paths to module directories """ modules: list[Path] = [] for item in src_root.iterdir(): if not item.is_dir(): continue if item.name in EXCLUDED_FROM_MODULE_SOLUTIONS: continue if item.name.startswith("."): continue # Check if it contains any csproj files csproj_files = list(item.rglob("*.csproj")) if csproj_files: modules.append(item.resolve()) return sorted(modules) def load_all_projects(src_root: Path) -> tuple[list[CsprojProject], dict[Path, CsprojProject]]: """ Load and parse all projects under src/. Returns: Tuple of (list of all projects, map from path to project) """ csproj_files = find_all_csproj(src_root) logger.info(f"Found {len(csproj_files)} .csproj files") projects: list[CsprojProject] = [] project_map: dict[Path, CsprojProject] = {} for csproj_path in csproj_files: project = parse_csproj(csproj_path, src_root) if project: projects.append(project) project_map[project.path] = project else: logger.warning(f"Failed to parse: {csproj_path}") logger.info(f"Successfully parsed {len(projects)} projects") return projects, project_map def generate_main_solution( src_root: Path, projects: list[CsprojProject], dry_run: bool = False, ) -> bool: """ Generate the main StellaOps.sln with all projects. Args: src_root: Root of src/ directory projects: All parsed projects dry_run: If True, don't write files Returns: True if successful """ sln_path = src_root / "StellaOps.sln" # Check for bypass marker if has_bypass_marker(sln_path): logger.info(f"Skipping {sln_path} (has bypass marker)") return True logger.info(f"Generating main solution: {sln_path}") # Build folder hierarchy matching physical structure folders = build_folder_hierarchy(projects, src_root) # Generate solution content content = generate_solution_content( sln_path=sln_path, projects=[], # Projects are in folders folders=folders, external_folders=None, add_bypass_marker=False, ) return write_solution_file(sln_path, content, dry_run) def generate_module_solution( module_dir: Path, src_root: Path, all_projects: list[CsprojProject], project_map: dict[Path, CsprojProject], dry_run: bool = False, ) -> bool: """ Generate a module-specific solution. Args: module_dir: Root directory of the module src_root: Root of src/ directory all_projects: All parsed projects project_map: Map from path to project dry_run: If True, don't write files Returns: True if successful """ module_name = module_dir.name sln_path = module_dir / f"StellaOps.{module_name}.sln" # Check for bypass marker if has_bypass_marker(sln_path): logger.info(f"Skipping {sln_path} (has bypass marker)") return True logger.info(f"Generating module solution: {sln_path}") # Get projects within this module module_projects = get_module_projects(module_dir, all_projects) if not module_projects: logger.warning(f"No projects found in module: {module_name}") return True logger.debug(f" Found {len(module_projects)} projects in module") # Build internal folder hierarchy internal_folders = build_folder_hierarchy(module_projects, module_dir) # Collect external dependencies external_groups = collect_all_external_dependencies( projects=module_projects, module_dir=module_dir, src_root=src_root, project_map=project_map, ) # Build external folder hierarchy external_folders = {} if external_groups: external_folders = build_external_folder_hierarchy(external_groups, src_root) ext_count = sum(len(v) for v in external_groups.values()) logger.debug(f" Found {ext_count} external dependencies") # Generate solution content content = generate_solution_content( sln_path=sln_path, projects=[], # Projects are in folders folders=internal_folders, external_folders=external_folders, add_bypass_marker=False, ) return write_solution_file(sln_path, content, dry_run) def check_solutions_up_to_date( src_root: Path, modules: list[Path], all_projects: list[CsprojProject], project_map: dict[Path, CsprojProject], ) -> bool: """ Check if solutions need updating (for --check mode). Args: src_root: Root of src/ directory modules: List of module directories all_projects: All parsed projects project_map: Map from path to project Returns: True if all solutions are up to date """ # This is a simplified check - in a real implementation, # you would compare generated content with existing files logger.info("Checking if solutions are up to date...") # For now, just check if files exist main_sln = src_root / "StellaOps.sln" if not main_sln.exists(): logger.error(f"Main solution missing: {main_sln}") return False for module_dir in modules: module_name = module_dir.name module_sln = module_dir / f"StellaOps.{module_name}.sln" if has_bypass_marker(module_sln): continue if not module_sln.exists(): logger.error(f"Module solution missing: {module_sln}") return False logger.info("All solutions appear to be up to date") return True def main() -> int: """Main entry point.""" parser = argparse.ArgumentParser( description="Generate StellaOps solution files", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=__doc__, ) parser.add_argument( "--src-root", type=Path, default=Path("src"), help="Root of src/ directory (default: ./src)", ) parser.add_argument( "--main-only", action="store_true", help="Only regenerate main solution", ) parser.add_argument( "--module", type=str, help="Regenerate specific module solution only", ) parser.add_argument( "--all", action="store_true", dest="regenerate_all", help="Regenerate all solutions (default)", ) parser.add_argument( "--dry-run", action="store_true", help="Show changes without writing", ) parser.add_argument( "--check", action="store_true", help="CI mode: exit 1 if solutions need updating", ) parser.add_argument( "-v", "--verbose", action="store_true", help="Verbose output", ) args = parser.parse_args() setup_logging(args.verbose) # Resolve src root src_root = args.src_root.resolve() if not src_root.exists(): logger.error(f"Source root does not exist: {src_root}") return 1 logger.info(f"Source root: {src_root}") # Load all projects all_projects, project_map = load_all_projects(src_root) if not all_projects: logger.error("No projects found") return 1 # Discover modules modules = discover_modules(src_root) logger.info(f"Discovered {len(modules)} modules") # Check mode if args.check: if check_solutions_up_to_date(src_root, modules, all_projects, project_map): return 0 return 1 # Determine what to generate success = True if args.module: # Specific module only module_dir = src_root / args.module if not module_dir.exists(): logger.error(f"Module directory does not exist: {module_dir}") return 1 success = generate_module_solution( module_dir, src_root, all_projects, project_map, args.dry_run ) elif args.main_only: # Main solution only success = generate_main_solution(src_root, all_projects, args.dry_run) else: # Generate all (default) # Main solution if not generate_main_solution(src_root, all_projects, args.dry_run): success = False # Module solutions for module_dir in modules: if not generate_module_solution( module_dir, src_root, all_projects, project_map, args.dry_run ): success = False if args.dry_run: logger.info("Dry run complete - no files were modified") return 0 if success else 1 if __name__ == "__main__": sys.exit(main())