|
|
@@ -1,6 +1,6 @@
|
|
|
#!/usr/bin/env python3
|
|
|
"""
|
|
|
-Cartographer - Repository mapping and change detection tool.
|
|
|
+Codemap cartographer - repository mapping and change detection tool.
|
|
|
|
|
|
Commands:
|
|
|
init Initialize mapping (create hashes + empty codemaps)
|
|
|
@@ -20,12 +20,13 @@ import os
|
|
|
import re
|
|
|
import sys
|
|
|
from datetime import datetime, timezone
|
|
|
-from pathlib import Path, PurePath
|
|
|
-from typing import Dict, List, Optional, Set, Tuple
|
|
|
+from pathlib import Path
|
|
|
+from typing import Dict, List, Optional, Set
|
|
|
|
|
|
VERSION = "1.0.0"
|
|
|
STATE_DIR = ".slim"
|
|
|
-STATE_FILE = "cartography.json"
|
|
|
+STATE_FILE = "codemap.json"
|
|
|
+LEGACY_STATE_FILE = "cartography.json"
|
|
|
CODEMAP_FILE = "codemap.md"
|
|
|
|
|
|
|
|
|
@@ -52,11 +53,10 @@ class PatternMatcher:
|
|
|
|
|
|
regex_parts = []
|
|
|
for pattern in patterns:
|
|
|
- # Regex conversion logic
|
|
|
reg = re.escape(pattern)
|
|
|
- reg = reg.replace(r'\*\*/', '(?:.*/)?') # Recursive glob
|
|
|
+ reg = reg.replace(r'\*\*/', '(?:.*/)?')
|
|
|
reg = reg.replace(r'\*\*', '.*')
|
|
|
- reg = reg.replace(r'\*', '[^/]*') # Single level glob
|
|
|
+ reg = reg.replace(r'\*', '[^/]*')
|
|
|
reg = reg.replace(r'\?', '.')
|
|
|
|
|
|
if pattern.endswith('/'):
|
|
|
@@ -66,10 +66,9 @@ class PatternMatcher:
|
|
|
reg = '^' + reg[1:]
|
|
|
else:
|
|
|
reg = '(?:^|.*/)' + reg
|
|
|
-
|
|
|
+
|
|
|
regex_parts.append(f'(?:{reg}$)')
|
|
|
-
|
|
|
- # Combine all patterns into a single regex for speed
|
|
|
+
|
|
|
combined_regex = '|'.join(regex_parts)
|
|
|
self.regex = re.compile(combined_regex)
|
|
|
|
|
|
@@ -89,42 +88,35 @@ def select_files(
|
|
|
) -> List[Path]:
|
|
|
"""Select files based on include/exclude patterns and exceptions."""
|
|
|
selected = []
|
|
|
-
|
|
|
- # Pre-compile matchers
|
|
|
+
|
|
|
include_matcher = PatternMatcher(include_patterns)
|
|
|
exclude_matcher = PatternMatcher(exclude_patterns)
|
|
|
gitignore_matcher = PatternMatcher(gitignore_patterns)
|
|
|
exception_set = set(exceptions)
|
|
|
-
|
|
|
+
|
|
|
root_str = str(root)
|
|
|
-
|
|
|
+
|
|
|
for dirpath, dirnames, filenames in os.walk(root_str):
|
|
|
- # Skip hidden directories early by modifying dirnames in-place
|
|
|
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
|
|
|
-
|
|
|
+
|
|
|
rel_dir = os.path.relpath(dirpath, root_str)
|
|
|
if rel_dir == ".":
|
|
|
rel_dir = ""
|
|
|
-
|
|
|
+
|
|
|
for filename in filenames:
|
|
|
rel_path = os.path.join(rel_dir, filename).replace("\\", "/")
|
|
|
if rel_path.startswith("./"):
|
|
|
rel_path = rel_path[2:]
|
|
|
-
|
|
|
- # Skip if ignored by .gitignore
|
|
|
+
|
|
|
if gitignore_matcher.matches(rel_path):
|
|
|
continue
|
|
|
-
|
|
|
- # Check explicit exclusions first
|
|
|
- if exclude_matcher.matches(rel_path):
|
|
|
- # Unless it's an exception
|
|
|
- if rel_path not in exception_set:
|
|
|
- continue
|
|
|
-
|
|
|
- # Check inclusions
|
|
|
+
|
|
|
+ if exclude_matcher.matches(rel_path) and rel_path not in exception_set:
|
|
|
+ continue
|
|
|
+
|
|
|
if include_matcher.matches(rel_path) or rel_path in exception_set:
|
|
|
selected.append(root / rel_path)
|
|
|
-
|
|
|
+
|
|
|
return sorted(selected)
|
|
|
|
|
|
|
|
|
@@ -142,17 +134,15 @@ def compute_file_hash(filepath: Path) -> str:
|
|
|
|
|
|
def compute_folder_hash(folder: str, file_hashes: Dict[str, str]) -> str:
|
|
|
"""Compute a stable hash for a folder based on its files."""
|
|
|
- # Get all files in this folder
|
|
|
folder_files = sorted(
|
|
|
(path, hash_val)
|
|
|
for path, hash_val in file_hashes.items()
|
|
|
if path.startswith(folder + "/") or (folder == "." and "/" not in path)
|
|
|
)
|
|
|
-
|
|
|
+
|
|
|
if not folder_files:
|
|
|
return ""
|
|
|
-
|
|
|
- # Hash the concatenation of path:hash pairs
|
|
|
+
|
|
|
hasher = hashlib.md5()
|
|
|
for path, hash_val in folder_files:
|
|
|
hasher.update(f"{path}:{hash_val}\n".encode())
|
|
|
@@ -164,16 +154,31 @@ def get_folders_with_files(files: List[Path], root: Path) -> Set[str]:
|
|
|
folders = set()
|
|
|
for f in files:
|
|
|
rel = f.relative_to(root)
|
|
|
- # Add all parent directories
|
|
|
- parts = rel.parts[:-1] # Exclude filename
|
|
|
+ parts = rel.parts[:-1]
|
|
|
for i in range(len(parts)):
|
|
|
folders.add("/".join(parts[: i + 1]))
|
|
|
- folders.add(".") # Always include root
|
|
|
+ folders.add(".")
|
|
|
return folders
|
|
|
|
|
|
|
|
|
+def migrate_legacy_state(root: Path) -> bool:
|
|
|
+ """Move legacy cartography state to codemap state if needed."""
|
|
|
+ state_dir = root / STATE_DIR
|
|
|
+ legacy_path = state_dir / LEGACY_STATE_FILE
|
|
|
+ state_path = state_dir / STATE_FILE
|
|
|
+
|
|
|
+ if state_path.exists() or not legacy_path.exists():
|
|
|
+ return False
|
|
|
+
|
|
|
+ state_dir.mkdir(parents=True, exist_ok=True)
|
|
|
+ legacy_path.replace(state_path)
|
|
|
+ print(f"Migrated {STATE_DIR}/{LEGACY_STATE_FILE} -> {STATE_DIR}/{STATE_FILE}")
|
|
|
+ return True
|
|
|
+
|
|
|
+
|
|
|
def load_state(root: Path) -> Optional[dict]:
|
|
|
- """Load the current cartography state."""
|
|
|
+ """Load the current codemap state, migrating legacy state if needed."""
|
|
|
+ migrate_legacy_state(root)
|
|
|
state_path = root / STATE_DIR / STATE_FILE
|
|
|
if state_path.exists():
|
|
|
try:
|
|
|
@@ -185,10 +190,10 @@ def load_state(root: Path) -> Optional[dict]:
|
|
|
|
|
|
|
|
|
def save_state(root: Path, state: dict) -> None:
|
|
|
- """Save the cartography state."""
|
|
|
+ """Save the codemap state."""
|
|
|
state_dir = root / STATE_DIR
|
|
|
state_dir.mkdir(parents=True, exist_ok=True)
|
|
|
-
|
|
|
+
|
|
|
state_path = state_dir / STATE_FILE
|
|
|
with open(state_path, "w", encoding="utf-8") as f:
|
|
|
json.dump(state, f, indent=2)
|
|
|
@@ -200,7 +205,7 @@ def create_empty_codemap(folder_path: Path, folder_name: str) -> None:
|
|
|
if not codemap_path.exists():
|
|
|
content = f"""# {folder_name}/
|
|
|
|
|
|
-<!-- Explorer: Fill in this section with architectural understanding -->
|
|
|
+<!-- Fixer: Fill in this section with architectural understanding -->
|
|
|
|
|
|
## Responsibility
|
|
|
|
|
|
@@ -225,42 +230,37 @@ def create_empty_codemap(folder_path: Path, folder_name: str) -> None:
|
|
|
def cmd_init(args: argparse.Namespace) -> int:
|
|
|
"""Initialize mapping: create hashes and empty codemaps."""
|
|
|
root = Path(args.root).resolve()
|
|
|
-
|
|
|
+
|
|
|
if not root.is_dir():
|
|
|
print(f"Error: {root} is not a directory", file=sys.stderr)
|
|
|
return 1
|
|
|
-
|
|
|
- # Load patterns
|
|
|
+
|
|
|
gitignore = load_gitignore(root)
|
|
|
include_patterns = args.include or ["**/*"]
|
|
|
exclude_patterns = args.exclude or []
|
|
|
exceptions = args.exception or []
|
|
|
-
|
|
|
+
|
|
|
print(f"Scanning {root}...")
|
|
|
print(f"Include patterns: {include_patterns}")
|
|
|
print(f"Exclude patterns: {exclude_patterns}")
|
|
|
print(f"Exceptions: {exceptions}")
|
|
|
-
|
|
|
- # Select files
|
|
|
+
|
|
|
selected_files = select_files(
|
|
|
root, include_patterns, exclude_patterns, exceptions, gitignore
|
|
|
)
|
|
|
-
|
|
|
+
|
|
|
print(f"Selected {len(selected_files)} files")
|
|
|
-
|
|
|
- # Compute file hashes
|
|
|
+
|
|
|
file_hashes: Dict[str, str] = {}
|
|
|
for f in selected_files:
|
|
|
rel_path = str(f.relative_to(root))
|
|
|
file_hashes[rel_path] = compute_file_hash(f)
|
|
|
-
|
|
|
- # Get folders and compute folder hashes
|
|
|
+
|
|
|
folders = get_folders_with_files(selected_files, root)
|
|
|
folder_hashes: Dict[str, str] = {}
|
|
|
for folder in folders:
|
|
|
folder_hashes[folder] = compute_folder_hash(folder, file_hashes)
|
|
|
-
|
|
|
- # Create state
|
|
|
+
|
|
|
state = {
|
|
|
"metadata": {
|
|
|
"version": VERSION,
|
|
|
@@ -273,12 +273,10 @@ def cmd_init(args: argparse.Namespace) -> int:
|
|
|
"file_hashes": file_hashes,
|
|
|
"folder_hashes": folder_hashes,
|
|
|
}
|
|
|
-
|
|
|
- # Save state
|
|
|
+
|
|
|
save_state(root, state)
|
|
|
print(f"Created {STATE_DIR}/{STATE_FILE}")
|
|
|
-
|
|
|
- # Create empty codemaps
|
|
|
+
|
|
|
for folder in folders:
|
|
|
if folder == ".":
|
|
|
folder_path = root
|
|
|
@@ -286,45 +284,41 @@ def cmd_init(args: argparse.Namespace) -> int:
|
|
|
else:
|
|
|
folder_path = root / folder
|
|
|
folder_name = folder
|
|
|
-
|
|
|
+
|
|
|
create_empty_codemap(folder_path, folder_name)
|
|
|
-
|
|
|
+
|
|
|
print(f"Created {len(folders)} empty codemap.md files")
|
|
|
-
|
|
|
+
|
|
|
return 0
|
|
|
|
|
|
|
|
|
def cmd_changes(args: argparse.Namespace) -> int:
|
|
|
"""Show what changed since last update."""
|
|
|
root = Path(args.root).resolve()
|
|
|
-
|
|
|
+
|
|
|
state = load_state(root)
|
|
|
if not state:
|
|
|
- print("No cartography state found. Run 'init' first.", file=sys.stderr)
|
|
|
+ print("No codemap state found. Run 'init' first.", file=sys.stderr)
|
|
|
return 1
|
|
|
-
|
|
|
- # Get patterns from saved state
|
|
|
+
|
|
|
metadata = state.get("metadata", {})
|
|
|
include_patterns = metadata.get("include_patterns", ["**/*"])
|
|
|
exclude_patterns = metadata.get("exclude_patterns", [])
|
|
|
exceptions = metadata.get("exceptions", [])
|
|
|
-
|
|
|
+
|
|
|
gitignore = load_gitignore(root)
|
|
|
-
|
|
|
- # Select current files
|
|
|
+
|
|
|
current_files = select_files(
|
|
|
root, include_patterns, exclude_patterns, exceptions, gitignore
|
|
|
)
|
|
|
-
|
|
|
- # Compute current hashes
|
|
|
+
|
|
|
current_hashes: Dict[str, str] = {}
|
|
|
for f in current_files:
|
|
|
rel_path = str(f.relative_to(root))
|
|
|
current_hashes[rel_path] = compute_file_hash(f)
|
|
|
-
|
|
|
+
|
|
|
saved_hashes = state.get("file_hashes", {})
|
|
|
-
|
|
|
- # Find changes
|
|
|
+
|
|
|
added = set(current_hashes.keys()) - set(saved_hashes.keys())
|
|
|
removed = set(saved_hashes.keys()) - set(current_hashes.keys())
|
|
|
modified = {
|
|
|
@@ -332,93 +326,86 @@ def cmd_changes(args: argparse.Namespace) -> int:
|
|
|
for path in current_hashes.keys() & saved_hashes.keys()
|
|
|
if current_hashes[path] != saved_hashes[path]
|
|
|
}
|
|
|
-
|
|
|
+
|
|
|
if not added and not removed and not modified:
|
|
|
print("No changes detected.")
|
|
|
return 0
|
|
|
-
|
|
|
+
|
|
|
if added:
|
|
|
print(f"\n{len(added)} added:")
|
|
|
for path in sorted(added):
|
|
|
print(f" + {path}")
|
|
|
-
|
|
|
+
|
|
|
if removed:
|
|
|
print(f"\n{len(removed)} removed:")
|
|
|
for path in sorted(removed):
|
|
|
print(f" - {path}")
|
|
|
-
|
|
|
+
|
|
|
if modified:
|
|
|
print(f"\n{len(modified)} modified:")
|
|
|
for path in sorted(modified):
|
|
|
print(f" ~ {path}")
|
|
|
-
|
|
|
- # Show affected folders
|
|
|
+
|
|
|
affected_folders = set()
|
|
|
for path in added | removed | modified:
|
|
|
parts = Path(path).parts[:-1]
|
|
|
for i in range(len(parts)):
|
|
|
affected_folders.add("/".join(parts[: i + 1]))
|
|
|
affected_folders.add(".")
|
|
|
-
|
|
|
+
|
|
|
print(f"\n{len(affected_folders)} folders affected:")
|
|
|
for folder in sorted(affected_folders):
|
|
|
print(f" {folder}/")
|
|
|
-
|
|
|
+
|
|
|
return 0
|
|
|
|
|
|
|
|
|
def cmd_update(args: argparse.Namespace) -> int:
|
|
|
"""Update hashes and save state."""
|
|
|
root = Path(args.root).resolve()
|
|
|
-
|
|
|
+
|
|
|
state = load_state(root)
|
|
|
if not state:
|
|
|
- print("No cartography state found. Run 'init' first.", file=sys.stderr)
|
|
|
+ print("No codemap state found. Run 'init' first.", file=sys.stderr)
|
|
|
return 1
|
|
|
-
|
|
|
- # Get patterns from saved state
|
|
|
+
|
|
|
metadata = state.get("metadata", {})
|
|
|
include_patterns = metadata.get("include_patterns", ["**/*"])
|
|
|
exclude_patterns = metadata.get("exclude_patterns", [])
|
|
|
exceptions = metadata.get("exceptions", [])
|
|
|
-
|
|
|
+
|
|
|
gitignore = load_gitignore(root)
|
|
|
-
|
|
|
- # Select current files
|
|
|
+
|
|
|
selected_files = select_files(
|
|
|
root, include_patterns, exclude_patterns, exceptions, gitignore
|
|
|
)
|
|
|
-
|
|
|
- # Compute new hashes
|
|
|
+
|
|
|
file_hashes: Dict[str, str] = {}
|
|
|
for f in selected_files:
|
|
|
rel_path = str(f.relative_to(root))
|
|
|
file_hashes[rel_path] = compute_file_hash(f)
|
|
|
-
|
|
|
- # Compute folder hashes
|
|
|
+
|
|
|
folders = get_folders_with_files(selected_files, root)
|
|
|
folder_hashes: Dict[str, str] = {}
|
|
|
for folder in folders:
|
|
|
folder_hashes[folder] = compute_folder_hash(folder, file_hashes)
|
|
|
-
|
|
|
- # Update state
|
|
|
+
|
|
|
state["metadata"]["last_run"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
|
state["file_hashes"] = file_hashes
|
|
|
state["folder_hashes"] = folder_hashes
|
|
|
-
|
|
|
+
|
|
|
save_state(root, state)
|
|
|
print(f"Updated {STATE_DIR}/{STATE_FILE} with {len(file_hashes)} files")
|
|
|
-
|
|
|
+
|
|
|
return 0
|
|
|
|
|
|
|
|
|
def main() -> int:
|
|
|
parser = argparse.ArgumentParser(
|
|
|
- description="Cartographer - Repository mapping and change detection"
|
|
|
+ description="Codemap cartographer - repository mapping and change detection"
|
|
|
)
|
|
|
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
|
|
-
|
|
|
- # Init command
|
|
|
+
|
|
|
init_parser = subparsers.add_parser("init", help="Initialize mapping")
|
|
|
init_parser.add_argument("--root", required=True, help="Repository root path")
|
|
|
init_parser.add_argument(
|
|
|
@@ -430,17 +417,15 @@ def main() -> int:
|
|
|
init_parser.add_argument(
|
|
|
"--exception", action="append", help="Explicit file paths to include despite exclusions"
|
|
|
)
|
|
|
-
|
|
|
- # Changes command
|
|
|
+
|
|
|
changes_parser = subparsers.add_parser("changes", help="Show what changed")
|
|
|
changes_parser.add_argument("--root", required=True, help="Repository root path")
|
|
|
-
|
|
|
- # Update command
|
|
|
+
|
|
|
update_parser = subparsers.add_parser("update", help="Update hashes")
|
|
|
update_parser.add_argument("--root", required=True, help="Repository root path")
|
|
|
-
|
|
|
+
|
|
|
args = parser.parse_args()
|
|
|
-
|
|
|
+
|
|
|
if args.command == "init":
|
|
|
return cmd_init(args)
|
|
|
elif args.command == "changes":
|