Skip to main content

#!/usr/bin/env python3 """ Reference Updater Script (ADR-100)

Updates all file references after nomenclature migration. Handles markdown links, import statements, and configuration paths.

Usage: python3 update-references.py --scan # Find all references python3 update-references.py --dry-run # Preview changes python3 update-references.py --execute # Execute updates python3 update-references.py --mapping mapping.json # Use custom mapping

Author: CODITECT Team Version: 1.0.0 Created: 2026-01-21 ADR: ADR-100 """

import argparse import json import os import re import sys from dataclasses import dataclass, field from datetime import datetime from pathlib import Path from typing import Dict, List, Optional, Set, Tuple

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

REFERENCE PATTERNS

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

Patterns to find and replace

REFERENCE_PATTERNS = { # Markdown links: text 'markdown_link': re.compile(r'[([^]]*)](([^)]+))'),

# Markdown references: [text]: path
'markdown_ref': re.compile(r'^\[([^\]]+)\]:\s*(.+)$', re.MULTILINE),

# Python imports: from path import
'python_import': re.compile(r'^from\s+([\w./]+)\s+import', re.MULTILINE),

# Python string paths: "path/to/file"
'python_path': re.compile(r'["\']([^"\']*(?:agents|commands|skills|scripts|hooks|docs)[^"\']*)["\']'),

# YAML/JSON paths
'yaml_path': re.compile(r':\s*["\']?([^"\':\n]*(?:agents|commands|skills|scripts|hooks|docs)[^"\':\n]*)["\']?'),

# Shell paths
'shell_path': re.compile(r'(?:^|\s)([./]*(?:agents|commands|skills|scripts|hooks|docs)[^\s]*)'),

}

Old to new path mappings (loaded from config or hardcoded)

PATH_MAPPINGS = { 'agents/': 'agents/', 'commands/': 'commands/', 'skills/': 'skills/', 'scripts/': 'scripts/', 'hooks/': 'hooks/', 'H.P.006-WORKFLOWS/': 'H.P.006-WORKFLOWS/', 'prompts/': 'prompts/', 'H.P.008-TEMPLATES/': 'H.P.008-TEMPLATES/', 'config/': 'config/',

'F.U.001-GETTING-STARTED/': 'F.U.001-GETTING-STARTED/',
'F.U.002-GUIDES/': 'F.U.002-GUIDES/',
'F.U.003-TUTORIALS/': 'F.U.003-TUTORIALS/',
'F.U.004-BEST-PRACTICES/': 'F.U.004-BEST-PRACTICES/',
'F.U.005-TROUBLESHOOTING/': 'F.U.005-TROUBLESHOOTING/',
'F.U.006-REFERENCE/': 'F.U.006-REFERENCE/',
'docs-customer/training/': 'F.U.007-TRAINING/',

'F.V.001-ADRS/': 'F.V.001-ADRS/',
'F.V.002-ARCHITECTURE/': 'F.V.002-ARCHITECTURE/',
'F.V.003-STANDARDS/': 'F.V.003-STANDARDS/',
'F.V.004-RESEARCH/': 'F.V.004-RESEARCH/',
'F.V.005-DEVELOPMENT/': 'F.V.005-DEVELOPMENT/',
'F.V.006-OPERATIONS/': 'F.V.006-OPERATIONS/',

'F.V.010-STANDARDS-CORE/': 'F.V.010-STANDARDS-CORE/',
'H.V.001-PROJECT-MANAGEMENT/': 'H.V.001-PROJECT-MANAGEMENT/',

}

File extensions to process

PROCESSABLE_EXTENSIONS = { '.md', '.py', '.sh', '.bash', '.zsh', '.yaml', '.yml', '.json', '.ts', '.tsx', '.js', '.jsx', '.html', '.css', '.scss', '.toml', '.ini', '.cfg', }

Directories to skip

SKIP_DIRECTORIES = { '.git', 'node_modules', 'pycache', '.venv', 'venv', 'env', '.env', 'dist', 'build', 'target', # Rust build artifacts 'playwright-report', 'test-results', # Test artifacts '.fingerprint', # Cargo fingerprints }

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

DATA CLASSES

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

@dataclass class Reference: """A single reference found in a file.""" file_path: Path line_number: int line_content: str old_path: str new_path: str pattern_type: str

@dataclass class FileUpdate: """Updates to be made to a single file.""" file_path: Path references: List[Reference] = field(default_factory=list) original_content: str = '' updated_content: str = '' status: str = 'pending'

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

FUNCTIONS

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

def load_mapping(mapping_path: Optional[Path]) -> Dict[str, str]: """Load path mappings from JSON file or use defaults.""" if mapping_path and mapping_path.exists(): data = json.loads(mapping_path.read_text())

    mappings = {}
for old_path, config in data.get('directory_mapping', {}).items():
domain = config.get('domain', '')
content = config['content']
seq = config['sequence']
name = config['name'].upper()

if domain:
new_path = f"{domain}.{content}.{seq}-{name}/"
else:
new_path = f"{content}.{seq}-{name}/"

mappings[old_path + '/'] = new_path

return mappings

return PATH_MAPPINGS

def should_process(path: Path) -> bool: """Check if file should be processed.""" # Skip certain directories for part in path.parts: if part in SKIP_DIRECTORIES: return False

# Check extension
return path.suffix.lower() in PROCESSABLE_EXTENSIONS

def is_valid_path_context(line: str, old_path: str) -> bool: """ Check if the path appears in a valid path context, not as part of variable names.

Returns False for cases like:
- has_hooks = True (hooks is part of variable name)
- configure_hooks() (hooks is part of function name)
- existing_commands (commands is part of variable name)

Returns True for cases like:
- ~/.coditect/hooks/ (actual path)
- "hooks/dispatcher.sh" (string path)
- from hooks import (import path)
- [link](hooks/file.md) (markdown link)
"""
old_stripped = old_path.rstrip('/')
idx = line.find(old_stripped)

if idx == -1:
return False

# Check character before the match
if idx > 0:
char_before = line[idx - 1]
# Valid: path separators, quotes, whitespace, brackets, colons
valid_before = set('/\\~"\'\t ({[:')
if char_before not in valid_before:
return False

# Check character after the match
end_idx = idx + len(old_stripped)
if end_idx < len(line):
char_after = line[end_idx]
# Valid: path separators, quotes, whitespace, brackets, dots (extensions)
valid_after = set('/\\"\'\t )}]:.')
if char_after not in valid_after:
return False

return True

def find_references( base_dir: Path, mappings: Dict[str, str] ) -> List[Reference]: """Find all references that need updating.""" references = []

for file_path in base_dir.rglob('*'):
if not file_path.is_file():
continue

if not should_process(file_path):
continue

try:
content = file_path.read_text(encoding='utf-8')
except (UnicodeDecodeError, PermissionError):
continue

lines = content.split('\n')

for line_num, line in enumerate(lines, 1):
for old_path, new_path in mappings.items():
old_stripped = old_path.rstrip('/')
# Check if old path appears in line AND in valid path context
if old_stripped in line and is_valid_path_context(line, old_path):
# Determine pattern type
pattern_type = 'unknown'
if '[' in line and '](' in line:
pattern_type = 'markdown_link'
elif line.strip().startswith('from '):
pattern_type = 'python_import'
elif ':' in line:
pattern_type = 'yaml_path'
elif '"' in line or "'" in line:
pattern_type = 'string_path'

references.append(Reference(
file_path=file_path,
line_number=line_num,
line_content=line.strip()[:100],
old_path=old_stripped,
new_path=new_path.rstrip('/'),
pattern_type=pattern_type,
))

return references

def create_path_pattern(old_path: str) -> re.Pattern: """ Create a regex pattern that matches a path only in valid path contexts.

This prevents replacing 'hooks' in 'has_hooks' or 'configure_hooks'.
Only matches when the path appears as an actual file/directory reference.

Valid contexts (before):
- Start of line
- Whitespace
- Path separators: / \\ ~
- Quotes: " '
- Brackets: ( [ {
- YAML/JSON: :

Valid contexts (after):
- End of line
- Whitespace
- Path separators: / \\
- Quotes: " '
- Brackets: ) ] }
- File extensions: .md .py .sh etc
"""
# Escape special regex characters in the path
escaped = re.escape(old_path.rstrip('/'))

# Build pattern with lookbehind and lookahead for valid path contexts
# Lookbehind: start of line, whitespace, path chars, quotes, brackets
lookbehind = r'(?:^|(?<=[/\\~"\'\s\(\[\{:]))'

# Lookahead: end of line, whitespace, path chars, quotes, brackets, extensions
lookahead = r'(?=[/\\\s"\'\)\]\}]|\.(?:md|py|sh|json|yaml|yml|ts|js)|$)'

return re.compile(lookbehind + escaped + lookahead, re.MULTILINE)

def update_file( file_path: Path, references: List[Reference], dry_run: bool = True ) -> FileUpdate: """Update a single file with new references.""" update = FileUpdate( file_path=file_path, references=references, )

try:
update.original_content = file_path.read_text(encoding='utf-8')
update.updated_content = update.original_content

# Apply all replacements using context-aware patterns
# This prevents replacing 'hooks' in variable names like 'has_hooks'
for ref in references:
pattern = create_path_pattern(ref.old_path)
update.updated_content = pattern.sub(ref.new_path, update.updated_content)

if update.original_content != update.updated_content:
if not dry_run:
file_path.write_text(update.updated_content, encoding='utf-8')
update.status = 'updated'
else:
update.status = 'pending'
else:
update.status = 'unchanged'

except Exception as e:
update.status = f'error: {e}'

return update

def scan_references(base_dir: Path, mappings: Dict[str, str]) -> None: """Scan and report all references.""" references = find_references(base_dir, mappings)

if not references:
print("No references found that need updating.")
return

# Group by file
by_file: Dict[Path, List[Reference]] = {}
for ref in references:
if ref.file_path not in by_file:
by_file[ref.file_path] = []
by_file[ref.file_path].append(ref)

print(f"\nFound {len(references)} references in {len(by_file)} files:\n")

for file_path, refs in sorted(by_file.items()):
rel_path = file_path.relative_to(base_dir) if file_path.is_relative_to(base_dir) else file_path
print(f"{rel_path}")

for ref in refs:
print(f" L{ref.line_number}: {ref.old_path} -> {ref.new_path}")
print(f" {ref.line_content[:60]}...")

print()

def execute_updates( base_dir: Path, mappings: Dict[str, str], dry_run: bool = True ) -> List[FileUpdate]: """Execute all reference updates.""" references = find_references(base_dir, mappings)

if not references:
print("No references found that need updating.")
return []

# Group by file
by_file: Dict[Path, List[Reference]] = {}
for ref in references:
if ref.file_path not in by_file:
by_file[ref.file_path] = []
by_file[ref.file_path].append(ref)

updates = []
for file_path, refs in by_file.items():
update = update_file(file_path, refs, dry_run)
updates.append(update)

rel_path = file_path.relative_to(base_dir) if file_path.is_relative_to(base_dir) else file_path

if update.status == 'updated':
print(f" UPDATED: {rel_path} ({len(refs)} references)")
elif update.status == 'pending':
print(f" PENDING: {rel_path} ({len(refs)} references)")
elif update.status == 'unchanged':
print(f" UNCHANGED: {rel_path}")
else:
print(f" ERROR: {rel_path} - {update.status}")

return updates

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

MAIN

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

def main(): parser = argparse.ArgumentParser( description='Reference Updater Script (ADR-100)' ) parser.add_argument( '--scan', action='store_true', help='Scan and report all references' ) parser.add_argument( '--dry-run', action='store_true', help='Preview changes without executing' ) parser.add_argument( '--execute', action='store_true', help='Execute reference updates' ) parser.add_argument( '--mapping', type=Path, help='Path to mapping JSON file' ) parser.add_argument( '--base-dir', type=Path, default=Path.cwd(), help='Base directory (default: current)' ) parser.add_argument( '--yes', '-y', action='store_true', help='Skip confirmation prompts (for automated execution)' )

args = parser.parse_args()

base_dir = args.base_dir.resolve()

print("=" * 60)
print("REFERENCE UPDATER (ADR-100)")
print("=" * 60)
print(f"Base directory: {base_dir}")

mappings = load_mapping(args.mapping)
print(f"Mappings loaded: {len(mappings)} paths")

if args.scan:
scan_references(base_dir, mappings)
return 0

if args.dry_run:
print("\nDRY RUN - No files will be modified\n")
updates = execute_updates(base_dir, mappings, dry_run=True)

updated = sum(1 for u in updates if u.status == 'pending')
print(f"\nWould update: {updated} files")
return 0

if args.execute:
print("\n⚠️ WARNING: This will modify files!")
if not args.yes:
confirm = input("Type 'update' to confirm: ")
if confirm != 'update':
print("Aborted.")
return 1
else:
print("Auto-confirmed with --yes flag")

print("\nExecuting updates...\n")
updates = execute_updates(base_dir, mappings, dry_run=False)

updated = sum(1 for u in updates if u.status == 'updated')
errors = sum(1 for u in updates if 'error' in u.status)

print(f"\nSummary:")
print(f" Updated: {updated}")
print(f" Errors: {errors}")

return 0 if errors == 0 else 1

parser.print_help()
return 1

if name == 'main': sys.exit(main())