Skip to main content

#!/usr/bin/env python3 """ Master orchestration script for lowercase naming migration.

This script coordinates the complete migration process:

  1. Generate inventory
  2. Process groups in priority order
  3. Rename directories (deepest first)
  4. Rename files
  5. Update references
  6. Validate results

Usage: python3 scripts/lowercase-migration/orchestrate-migration.py python3 scripts/lowercase-migration/orchestrate-migration.py --dry-run python3 scripts/lowercase-migration/orchestrate-migration.py --group standards python3 scripts/lowercase-migration/orchestrate-migration.py --list-groups """

import os import json import subprocess import sys import re from pathlib import Path from datetime import datetime from collections import defaultdict from typing import Dict, List, Set, Tuple, Optional

============================================================================

CONFIGURATION

============================================================================

Files that must NEVER be renamed (case-sensitive exact matches)

EXCEPTIONS = { 'README.md', 'CLAUDE.md', 'SKILL.md', # Claude Code standard for skill entry points 'LICENSE', 'LICENSE.md', 'CHANGELOG.md', 'CONTRIBUTING.md', 'CODE_OF_CONDUCT.md', 'CODITECT.md', 'Makefile', 'Dockerfile', 'Cargo.toml', 'Cargo.lock', 'package.json', 'package-lock.json', 'pyproject.toml', 'setup.py', 'go.mod', 'go.sum', }

Directories to skip entirely

SKIP_DIRS = { '.git', '.venv', 'venv', 'node_modules', 'pycache', '.mypy_cache', '.pytest_cache', 'target', 'dist', 'build', '.tox', '.ruff_cache', }

External standards to preserve (regex patterns)

PRESERVE_PATTERNS = [ r'^ISO-IEC\d+', r'^RFC\d+', r'^W3C-', ]

Processing groups with priorities

GROUPS = { 'standards': { 'priority': 0, 'paths': ['coditect-core-standards/'], 'description': 'Framework standards files', }, 'tracks': { 'priority': 0, 'paths': ['internal/project/plans/tracks/'], 'description': 'Track plan files', }, 'internal': { 'priority': 1, 'paths': ['internal/'], 'description': 'Internal documentation', 'exclude': ['internal/project/plans/tracks/'], }, 'docs': { 'priority': 1, 'paths': ['docs/', 'docs-contributor/'], 'description': 'Customer documentation', }, 'skills': { 'priority': 1, 'paths': ['skills/'], 'description': 'Skill files', }, 'agents': { 'priority': 2, 'paths': ['agents/'], 'description': 'Agent files', }, 'commands': { 'priority': 2, 'paths': ['commands/'], 'description': 'Command files', }, 'scripts': { 'priority': 2, 'paths': ['scripts/'], 'description': 'Script files', 'exclude': ['scripts/lowercase-migration/'], }, 'templates': { 'priority': 2, 'paths': ['templates/'], 'description': 'Template files', }, 'config': { 'priority': 2, 'paths': ['config/'], 'description': 'Configuration files', }, 'hooks': { 'priority': 2, 'paths': ['hooks/'], 'description': 'Hook files', }, 'research': { 'priority': 3, 'paths': ['analyze-new-artifacts/', 'codanna/'], 'description': 'Research and analysis files', }, 'context': { 'priority': 3, 'paths': ['context-storage/'], 'description': 'Context storage files', }, 'external': { 'priority': 3, 'paths': ['external/', 'lib/'], 'description': 'External and library files', }, 'tools': { 'priority': 3, 'paths': ['tools/'], 'description': 'Tool files', }, 'submodules': { 'priority': 4, 'paths': ['submodules/'], 'description': 'Submodule files', }, }

============================================================================

UTILITIES

============================================================================

def has_uppercase(name: str) -> bool: """Check if a name contains uppercase letters.""" return bool(re.search(r'[A-Z]', name))

def is_exception(name: str) -> bool: """Check if a name is in the exceptions list.""" return name in EXCEPTIONS

def should_preserve(name: str) -> bool: """Check if a name matches external standard patterns.""" for pattern in PRESERVE_PATTERNS: if re.match(pattern, name): return True return False

def get_lowercase_name(name: str) -> str: """Convert a name to lowercase.""" return name.lower()

def print_header(text: str, char: str = '='): """Print a formatted header.""" width = 70 print() print(char * width) print(f" {text}") print(char * width)

def print_subheader(text: str): """Print a formatted subheader.""" print(f"\n--- {text} ---")

def run_command(cmd: list, dry_run: bool = False) -> Tuple[bool, str]: """Run a shell command and return success status and output.""" if dry_run: return True, f"[DRY-RUN] Would run: {' '.join(cmd)}"

try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
check=True
)
return True, result.stdout
except subprocess.CalledProcessError as e:
return False, e.stderr

============================================================================

INVENTORY

============================================================================

def scan_directory(root_path: Path) -> Dict: """Scan directory and return inventory of items to rename.""" inventory = { 'directories': [], 'files': [], 'exceptions': [], 'preserved': [], 'stats': defaultdict(int), 'by_group': defaultdict(lambda: {'dirs': [], 'files': []}), }

for dirpath, dirnames, filenames in os.walk(root_path):
# Skip excluded directories
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]

rel_dirpath = Path(dirpath).relative_to(root_path)

# Determine group for this path
group = get_group_for_path(str(rel_dirpath))

# Check directories
for dirname in list(dirnames):
if has_uppercase(dirname):
full_path = Path(dirpath) / dirname
rel_path = rel_dirpath / dirname

if should_preserve(dirname):
inventory['preserved'].append({
'type': 'directory',
'path': str(rel_path),
'name': dirname,
'reason': 'external_standard',
})
inventory['stats']['directories_preserved'] += 1
else:
item = {
'path': str(rel_path),
'name': dirname,
'new_name': get_lowercase_name(dirname),
'depth': len(rel_path.parts),
'group': group,
}
inventory['directories'].append(item)
inventory['by_group'][group]['dirs'].append(item)
inventory['stats']['directories_to_rename'] += 1

# Check files
for filename in filenames:
if has_uppercase(filename):
full_path = Path(dirpath) / filename
rel_path = rel_dirpath / filename

if is_exception(filename):
inventory['exceptions'].append({
'type': 'file',
'path': str(rel_path),
'name': filename,
'reason': 'industry_standard',
})
inventory['stats']['files_exception'] += 1
elif should_preserve(filename):
inventory['preserved'].append({
'type': 'file',
'path': str(rel_path),
'name': filename,
'reason': 'external_standard',
})
inventory['stats']['files_preserved'] += 1
else:
item = {
'path': str(rel_path),
'name': filename,
'new_name': get_lowercase_name(filename),
'extension': Path(filename).suffix,
'group': group,
}
inventory['files'].append(item)
inventory['by_group'][group]['files'].append(item)
inventory['stats']['files_to_rename'] += 1

# Sort directories by depth (deepest first for safe renaming)
inventory['directories'].sort(key=lambda x: -x['depth'])

return inventory

def get_group_for_path(path: str) -> str: """Determine which group a path belongs to.""" path_lower = path.lower()

for group_name, group_config in GROUPS.items():
# Check exclusions first
if 'exclude' in group_config:
excluded = False
for exclude_path in group_config['exclude']:
if path_lower.startswith(exclude_path.lower()):
excluded = True
break
if excluded:
continue

# Check if path matches any group path
for group_path in group_config['paths']:
if path_lower.startswith(group_path.lower()) or path_lower == group_path.lower().rstrip('/'):
return group_name

return 'other'

============================================================================

RENAME OPERATIONS

============================================================================

def rename_item(old_path: Path, new_path: Path, dry_run: bool = False) -> bool: """Rename a file or directory using git mv.""" if dry_run: print(f" [DRY-RUN] git mv '{old_path}' '{new_path}'") return True

success, output = run_command(['git', 'mv', str(old_path), str(new_path)])
if success:
print(f" [OK] {old_path.name} → {new_path.name}")
else:
print(f" [ERROR] {old_path}: {output}")
return success

def rename_directories_in_group( inventory: Dict, root_path: Path, group: str, dry_run: bool = False ) -> int: """Rename all directories in a specific group.""" dirs = inventory['by_group'].get(group, {}).get('dirs', []) if not dirs: return 0

print_subheader(f"Renaming {len(dirs)} directories in '{group}'")

# Sort by depth (deepest first)
dirs_sorted = sorted(dirs, key=lambda x: -x['depth'])

success_count = 0
for item in dirs_sorted:
old_path = root_path / item['path']
new_name = item['new_name']
new_path = old_path.parent / new_name

if not old_path.exists():
# Check if already renamed
if new_path.exists():
print(f" [SKIP] Already renamed: {item['path']}")
continue
print(f" [SKIP] Not found: {item['path']}")
continue

if rename_item(old_path, new_path, dry_run):
success_count += 1

return success_count

def rename_files_in_group( inventory: Dict, root_path: Path, group: str, dry_run: bool = False ) -> int: """Rename all files in a specific group.""" files = inventory['by_group'].get(group, {}).get('files', []) if not files: return 0

print_subheader(f"Renaming {len(files)} files in '{group}'")

success_count = 0
for item in files:
# Path might have changed if parent directory was renamed
old_path = root_path / item['path']

# If original path doesn't exist, try lowercase directory path
if not old_path.exists():
# Try with lowercased parent path
parts = Path(item['path']).parts
if len(parts) > 1:
lowercase_parent = '/'.join(p.lower() for p in parts[:-1])
old_path = root_path / lowercase_parent / item['name']

if not old_path.exists():
print(f" [SKIP] Not found: {item['path']}")
continue

new_name = item['new_name']
new_path = old_path.parent / new_name

if old_path == new_path:
continue

if rename_item(old_path, new_path, dry_run):
success_count += 1

return success_count

============================================================================

REFERENCE UPDATES

============================================================================

def update_references_for_group( inventory: Dict, root_path: Path, group: str, dry_run: bool = False ) -> int: """Update references for items renamed in a group.""" # Build rename map for this group rename_map = {}

for item in inventory['by_group'].get(group, {}).get('dirs', []):
rename_map[item['name']] = item['new_name']
rename_map[item['path']] = item['path'].lower()

for item in inventory['by_group'].get(group, {}).get('files', []):
rename_map[item['name']] = item['new_name']
rename_map[item['path']] = item['path'].lower()

if not rename_map:
return 0

print_subheader(f"Updating references for '{group}' ({len(rename_map)} mappings)")

# Scan all files for references
files_updated = 0
scannable_extensions = {'.md', '.py', '.sh', '.json', '.yaml', '.yml', '.toml', '.txt'}

for dirpath, dirnames, filenames in os.walk(root_path):
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]

for filename in filenames:
ext = Path(filename).suffix.lower()
if ext not in scannable_extensions:
continue

file_path = Path(dirpath) / filename

try:
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
content = f.read()
except Exception:
continue

updated_content = content
changes = []

# Sort by length (longest first) to avoid partial replacements
for old, new in sorted(rename_map.items(), key=lambda x: -len(x[0])):
if old == new:
continue

# Various reference patterns
patterns = [
(f']({old})', f']({new})'),
(f']({old}#', f']({new}#'),
(f'"{old}"', f'"{new}"'),
(f"'{old}'", f"'{new}'"),
(f'/{old})', f'/{new})'),
(f'/{old}"', f'/{new}"'),
]

for pattern, replacement in patterns:
if pattern in updated_content:
updated_content = updated_content.replace(pattern, replacement)
changes.append(f"{pattern} → {replacement}")

if changes and updated_content != content:
if dry_run:
rel_path = file_path.relative_to(root_path)
print(f" [DRY-RUN] Would update: {rel_path} ({len(changes)} changes)")
else:
with open(file_path, 'w', encoding='utf-8') as f:
f.write(updated_content)
rel_path = file_path.relative_to(root_path)
print(f" [UPDATED] {rel_path} ({len(changes)} changes)")
files_updated += 1

return files_updated

============================================================================

VALIDATION

============================================================================

def validate_group( inventory: Dict, root_path: Path, group: str ) -> Tuple[int, int]: """Validate that all items in a group were renamed.""" dirs = inventory['by_group'].get(group, {}).get('dirs', []) files = inventory['by_group'].get(group, {}).get('files', [])

success = 0
failed = 0

for item in dirs:
new_path = root_path / item['path'].lower()
if new_path.exists():
success += 1
else:
print(f" [MISSING] Directory: {item['path'].lower()}")
failed += 1

for item in files:
new_path = root_path / item['path'].lower()
if new_path.exists():
success += 1
else:
print(f" [MISSING] File: {item['path'].lower()}")
failed += 1

return success, failed

============================================================================

MAIN ORCHESTRATION

============================================================================

def process_group( inventory: Dict, root_path: Path, group: str, dry_run: bool = False ) -> Dict: """Process a single group: rename dirs, rename files, update references.""" print_header(f"Processing Group: {group.upper()}", char='-')

results = {
'group': group,
'dirs_renamed': 0,
'files_renamed': 0,
'refs_updated': 0,
'validation': {'success': 0, 'failed': 0},
}

# Step 1: Rename directories (deepest first)
results['dirs_renamed'] = rename_directories_in_group(
inventory, root_path, group, dry_run
)

# Step 2: Rename files
results['files_renamed'] = rename_files_in_group(
inventory, root_path, group, dry_run
)

# Step 3: Update references
results['refs_updated'] = update_references_for_group(
inventory, root_path, group, dry_run
)

# Step 4: Validate
if not dry_run:
print_subheader(f"Validating '{group}'")
success, failed = validate_group(inventory, root_path, group)
results['validation'] = {'success': success, 'failed': failed}
print(f" Validated: {success} OK, {failed} missing")

return results

def main(): """Main entry point.""" # Parse arguments dry_run = '--dry-run' in sys.argv list_groups = '--list-groups' in sys.argv target_group = None

for arg in sys.argv[1:]:
if arg.startswith('--group='):
target_group = arg.split('=')[1]
elif arg == '--group' and sys.argv.index(arg) + 1 < len(sys.argv):
target_group = sys.argv[sys.argv.index(arg) + 1]

# Find root path
script_path = Path(__file__).resolve()
root_path = script_path.parent.parent.parent

if not (root_path / 'CLAUDE.md').exists():
print(f"Error: Could not find coditect-core root at {root_path}")
sys.exit(1)

# List groups mode
if list_groups:
print_header("AVAILABLE GROUPS")
sorted_groups = sorted(GROUPS.items(), key=lambda x: x[1]['priority'])
for name, config in sorted_groups:
print(f"\n {name}")
print(f" Priority: P{config['priority']}")
print(f" Paths: {', '.join(config['paths'])}")
print(f" Description: {config['description']}")
sys.exit(0)

# Print header
print_header("LOWERCASE NAMING MIGRATION ORCHESTRATOR")
print(f"Root: {root_path}")
print(f"Mode: {'DRY-RUN' if dry_run else 'LIVE'}")
print(f"Target: {target_group or 'ALL GROUPS'}")

# Change to root directory
os.chdir(root_path)

# Generate inventory
print_header("GENERATING INVENTORY")
inventory = scan_directory(root_path)

print(f"\nTotal directories to rename: {inventory['stats']['directories_to_rename']}")
print(f"Total files to rename: {inventory['stats']['files_to_rename']}")
print(f"Exception files (preserved): {inventory['stats']['files_exception']}")

# Show breakdown by group
print("\nBreakdown by group:")
for group_name in sorted(inventory['by_group'].keys()):
group_data = inventory['by_group'][group_name]
dir_count = len(group_data['dirs'])
file_count = len(group_data['files'])
print(f" {group_name:20} dirs: {dir_count:4} files: {file_count:5}")

# Save inventory
output_dir = root_path / 'context-storage' / 'lowercase-migration'
output_dir.mkdir(parents=True, exist_ok=True)
with open(output_dir / 'inventory.json', 'w') as f:
# Convert defaultdict to dict for JSON serialization
inventory_export = {
k: dict(v) if isinstance(v, defaultdict) else v
for k, v in inventory.items()
}
json.dump(inventory_export, f, indent=2, default=str)
print(f"\nInventory saved to: {output_dir / 'inventory.json'}")

# Confirmation for live mode
if not dry_run:
print("\n" + "!" * 70)
print("WARNING: This will rename files and directories using git mv!")
print("Make sure you have committed all changes and created a backup branch.")
print("!" * 70)
response = input("\nProceed? (yes/no): ")
if response.lower() != 'yes':
print("Aborted.")
sys.exit(0)

# Process groups
all_results = []
sorted_groups = sorted(GROUPS.items(), key=lambda x: x[1]['priority'])

for group_name, group_config in sorted_groups:
# Skip if targeting specific group
if target_group and group_name != target_group:
continue

# Skip if group has no items
if group_name not in inventory['by_group']:
continue

results = process_group(inventory, root_path, group_name, dry_run)
all_results.append(results)

# Commit after each group in live mode
if not dry_run and (results['dirs_renamed'] > 0 or results['files_renamed'] > 0):
print_subheader(f"Committing '{group_name}' changes")
run_command(['git', 'add', '-A'])
run_command([
'git', 'commit', '-m',
f"refactor(naming): migrate {group_name} to lowercase\n\n"
f"Directories renamed: {results['dirs_renamed']}\n"
f"Files renamed: {results['files_renamed']}\n"
f"References updated: {results['refs_updated']}\n\n"
f"Part of Track AM: Lowercase Naming Migration\n\n"
f"Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
])
print(" [COMMITTED]")

# Final summary
print_header("MIGRATION SUMMARY")

total_dirs = sum(r['dirs_renamed'] for r in all_results)
total_files = sum(r['files_renamed'] for r in all_results)
total_refs = sum(r['refs_updated'] for r in all_results)

print(f"\nTotal directories renamed: {total_dirs}")
print(f"Total files renamed: {total_files}")
print(f"Total references updated: {total_refs}")

if dry_run:
print("\nThis was a DRY-RUN. No files were actually modified.")
print("To execute, run: python3 scripts/lowercase-migration/orchestrate-migration.py")

if name == 'main': main()