Эх сурвалжийг харах

rewrite codemap cartographer in node

Alvin Unreal 3 сар өмнө
parent
commit
ee8bce6616

+ 3 - 3
docs/codemap.md

@@ -23,13 +23,13 @@ From a repo root (or with an explicit `--root`):
 
 ```bash
 # Initialize mapping
-python3 cartographer.py init --root /repo --include "src/**/*.ts" --exclude "node_modules/**"
+node cartographer.mjs init --root /repo --include "src/**/*.ts" --exclude "node_modules/**"
 
 # Check what changed
-python3 cartographer.py changes --root /repo
+node cartographer.mjs changes --root /repo
 
 # Update hashes
-python3 cartographer.py update --root /repo
+node cartographer.mjs update --root /repo
 ```
 
 ## Outputs

+ 3 - 3
src/skills/codemap/README.md

@@ -16,13 +16,13 @@ Legacy `.slim/cartography.json` state is migrated to `.slim/codemap.json` automa
 
 ```bash
 # Initialize mapping
-python3 cartographer.py init --root /repo --include "src/**/*.ts" --exclude "node_modules/**"
+node cartographer.mjs init --root /repo --include "src/**/*.ts" --exclude "node_modules/**"
 
 # Check what changed
-python3 cartographer.py changes --root /repo
+node cartographer.mjs changes --root /repo
 
 # Update hashes
-python3 cartographer.py update --root /repo
+node cartographer.mjs update --root /repo
 ```
 
 ## Outputs

+ 5 - 5
src/skills/codemap/SKILL.md

@@ -37,10 +37,10 @@ If neither file exists: Continue to Step 2 (Initialize).
      - Docs: `docs/**`, `*.md` (except root `README.md` if needed), `LICENSE`
      - Build/Deps: `node_modules/**`, `dist/**`, `build/**`, `*.min.js`
    - Respect `.gitignore` automatically
-3. **Run cartographer.py init**:
+3. **Run cartographer.mjs init**:
 
 ```bash
-python3 ~/.config/opencode/skills/codemap/scripts/cartographer.py init \
+node ~/.config/opencode/skills/codemap/scripts/cartographer.mjs init \
   --root ./ \
   --include "src/**/*.ts" \
   --exclude "**/*.test.ts" --exclude "dist/**" --exclude "node_modules/**"
@@ -54,10 +54,10 @@ This creates:
 
 ### Step 3: Detect Changes (If state already exists)
 
-1. **Run cartographer.py changes** to see what changed:
+1. **Run cartographer.mjs changes** to see what changed:
 
 ```bash
-python3 ~/.config/opencode/skills/codemap/scripts/cartographer.py changes \
+node ~/.config/opencode/skills/codemap/scripts/cartographer.mjs changes \
   --root ./
 ```
 
@@ -71,7 +71,7 @@ python3 ~/.config/opencode/skills/codemap/scripts/cartographer.py changes \
 4. **Run update** to save new state:
 
 ```bash
-python3 ~/.config/opencode/skills/codemap/scripts/cartographer.py update \
+node ~/.config/opencode/skills/codemap/scripts/cartographer.mjs update \
   --root ./
 ```
 

+ 483 - 0
src/skills/codemap/scripts/cartographer.mjs

@@ -0,0 +1,483 @@
+#!/usr/bin/env node
+
+import { createHash } from 'node:crypto';
+import {
+  existsSync,
+  mkdirSync,
+  readdirSync,
+  readFileSync,
+  renameSync,
+  statSync,
+  writeFileSync,
+} from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+export const VERSION = '1.0.0';
+export const STATE_DIR = '.slim';
+export const STATE_FILE = 'codemap.json';
+export const LEGACY_STATE_FILE = 'cartography.json';
+export const CODEMAP_FILE = 'codemap.md';
+
+export class PatternMatcher {
+  regex;
+
+  constructor(patterns) {
+    if (!patterns.length) {
+      this.regex = null;
+      return;
+    }
+
+    const regexParts = patterns.map((pattern) => {
+      let reg = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+      reg = reg.replace(/\\\*\\\*\//g, '(?:.*/)?');
+      reg = reg.replace(/\\\*\\\*/g, '.*');
+      reg = reg.replace(/\\\*/g, '[^/]*');
+      reg = reg.replace(/\\\?/g, '.');
+
+      if (pattern.endsWith('/')) {
+        reg += '.*';
+      }
+
+      if (pattern.startsWith('/')) {
+        reg = `^${reg.slice(1)}`;
+      } else {
+        reg = `(?:^|.*/)${reg}`;
+      }
+
+      return `(?:${reg}$)`;
+    });
+
+    this.regex = new RegExp(regexParts.join('|'));
+  }
+
+  matches(filePath) {
+    if (!this.regex) return false;
+    return this.regex.test(filePath);
+  }
+}
+
+export function loadGitignore(root) {
+  const gitignorePath = path.join(root, '.gitignore');
+  if (!existsSync(gitignorePath)) return [];
+
+  return readFileSync(gitignorePath, 'utf8')
+    .split('\n')
+    .map((line) => line.trim())
+    .filter((line) => line && !line.startsWith('#'));
+}
+
+function walkFiles(root) {
+  const files = [];
+
+  function visit(currentDir) {
+    for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
+      const fullPath = path.join(currentDir, entry.name);
+      if (entry.isDirectory()) {
+        if (!entry.name.startsWith('.')) {
+          visit(fullPath);
+        }
+        continue;
+      }
+
+      if (entry.isFile()) {
+        files.push(fullPath);
+      }
+    }
+  }
+
+  visit(root);
+  return files.sort();
+}
+
+export function selectFiles(
+  root,
+  includePatterns,
+  excludePatterns,
+  exceptions,
+  gitignorePatterns,
+) {
+  const includeMatcher = new PatternMatcher(includePatterns);
+  const excludeMatcher = new PatternMatcher(excludePatterns);
+  const gitignoreMatcher = new PatternMatcher(gitignorePatterns);
+  const exceptionSet = new Set(exceptions);
+
+  return walkFiles(root).filter((fullPath) => {
+    let relPath = path.relative(root, fullPath).replaceAll(path.sep, '/');
+    if (relPath.startsWith('./')) {
+      relPath = relPath.slice(2);
+    }
+
+    if (gitignoreMatcher.matches(relPath)) return false;
+    if (excludeMatcher.matches(relPath) && !exceptionSet.has(relPath)) {
+      return false;
+    }
+
+    return includeMatcher.matches(relPath) || exceptionSet.has(relPath);
+  });
+}
+
+export function computeFileHash(filePath) {
+  try {
+    const buffer = readFileSync(filePath);
+    return createHash('md5').update(buffer).digest('hex');
+  } catch {
+    return '';
+  }
+}
+
+export function computeFolderHash(folder, fileHashes) {
+  const folderFiles = Object.entries(fileHashes)
+    .filter(
+      ([filePath]) =>
+        filePath.startsWith(`${folder}/`) ||
+        (folder === '.' && !filePath.includes('/')),
+    )
+    .sort(([a], [b]) => a.localeCompare(b));
+
+  if (!folderFiles.length) return '';
+
+  const hasher = createHash('md5');
+  for (const [filePath, hash] of folderFiles) {
+    hasher.update(`${filePath}:${hash}\n`);
+  }
+  return hasher.digest('hex');
+}
+
+export function getFoldersWithFiles(files, root) {
+  const folders = new Set(['.']);
+
+  for (const filePath of files) {
+    const relPath = path.relative(root, filePath).replaceAll(path.sep, '/');
+    const parts = relPath.split('/').slice(0, -1);
+    for (let i = 0; i < parts.length; i++) {
+      folders.add(parts.slice(0, i + 1).join('/'));
+    }
+  }
+
+  return folders;
+}
+
+export function migrateLegacyState(root) {
+  const stateDir = path.join(root, STATE_DIR);
+  const legacyPath = path.join(stateDir, LEGACY_STATE_FILE);
+  const statePath = path.join(stateDir, STATE_FILE);
+
+  if (existsSync(statePath) || !existsSync(legacyPath)) {
+    return false;
+  }
+
+  mkdirSync(stateDir, { recursive: true });
+  renameSync(legacyPath, statePath);
+  console.log(
+    `Migrated ${STATE_DIR}/${LEGACY_STATE_FILE} -> ${STATE_DIR}/${STATE_FILE}`,
+  );
+  return true;
+}
+
+export function loadState(root) {
+  migrateLegacyState(root);
+  const statePath = path.join(root, STATE_DIR, STATE_FILE);
+  if (!existsSync(statePath)) return null;
+
+  try {
+    return JSON.parse(readFileSync(statePath, 'utf8'));
+  } catch {
+    return null;
+  }
+}
+
+export function saveState(root, state) {
+  const stateDir = path.join(root, STATE_DIR);
+  mkdirSync(stateDir, { recursive: true });
+  writeFileSync(
+    path.join(stateDir, STATE_FILE),
+    `${JSON.stringify(state, null, 2)}\n`,
+  );
+}
+
+export function createEmptyCodemap(folderPath, folderName) {
+  const codemapPath = path.join(folderPath, CODEMAP_FILE);
+  if (existsSync(codemapPath)) return;
+
+  const content = `# ${folderName}/
+
+<!-- Fixer: Fill in this section with architectural understanding -->
+
+## Responsibility
+
+<!-- What is this folder's job in the system? -->
+
+## Design
+
+<!-- Key patterns, abstractions, architectural decisions -->
+
+## Flow
+
+<!-- How does data/control flow through this module? -->
+
+## Integration
+
+<!-- How does it connect to other parts of the system? -->
+`;
+
+  writeFileSync(codemapPath, content);
+}
+
+function buildState(
+  root,
+  includePatterns,
+  excludePatterns,
+  exceptions,
+  selectedFiles,
+) {
+  const fileHashes = {};
+  for (const filePath of selectedFiles) {
+    const relPath = path.relative(root, filePath).replaceAll(path.sep, '/');
+    fileHashes[relPath] = computeFileHash(filePath);
+  }
+
+  const folders = getFoldersWithFiles(selectedFiles, root);
+  const folderHashes = {};
+  for (const folder of folders) {
+    folderHashes[folder] = computeFolderHash(folder, fileHashes);
+  }
+
+  const state = {
+    metadata: {
+      version: VERSION,
+      last_run: new Date().toISOString(),
+      root,
+      include_patterns: includePatterns,
+      exclude_patterns: excludePatterns,
+      exceptions,
+    },
+    file_hashes: fileHashes,
+    folder_hashes: folderHashes,
+  };
+
+  return { state, folders };
+}
+
+export function cmdInit({ root, include = [], exclude = [], exception = [] }) {
+  const resolvedRoot = path.resolve(root);
+  if (!existsSync(resolvedRoot) || !statSync(resolvedRoot).isDirectory()) {
+    console.error(`Error: ${resolvedRoot} is not a directory`);
+    return 1;
+  }
+
+  const includePatterns = include.length ? include : ['**/*'];
+  const excludePatterns = exclude;
+  const exceptions = exception;
+  const gitignore = loadGitignore(resolvedRoot);
+
+  console.log(`Scanning ${resolvedRoot}...`);
+  console.log(`Include patterns: ${JSON.stringify(includePatterns)}`);
+  console.log(`Exclude patterns: ${JSON.stringify(excludePatterns)}`);
+  console.log(`Exceptions: ${JSON.stringify(exceptions)}`);
+
+  const selectedFiles = selectFiles(
+    resolvedRoot,
+    includePatterns,
+    excludePatterns,
+    exceptions,
+    gitignore,
+  );
+
+  console.log(`Selected ${selectedFiles.length} files`);
+
+  const { state, folders } = buildState(
+    resolvedRoot,
+    includePatterns,
+    excludePatterns,
+    exceptions,
+    selectedFiles,
+  );
+
+  saveState(resolvedRoot, state);
+  console.log(`Created ${STATE_DIR}/${STATE_FILE}`);
+
+  for (const folder of folders) {
+    const folderPath =
+      folder === '.' ? resolvedRoot : path.join(resolvedRoot, folder);
+    const folderName = folder === '.' ? path.basename(resolvedRoot) : folder;
+    createEmptyCodemap(folderPath, folderName);
+  }
+
+  console.log(`Created ${folders.size} empty codemap.md files`);
+  return 0;
+}
+
+export function cmdChanges({ root }) {
+  const resolvedRoot = path.resolve(root);
+  const state = loadState(resolvedRoot);
+  if (!state) {
+    console.error("No codemap state found. Run 'init' first.");
+    return 1;
+  }
+
+  const metadata = state.metadata ?? {};
+  const includePatterns = metadata.include_patterns ?? ['**/*'];
+  const excludePatterns = metadata.exclude_patterns ?? [];
+  const exceptions = metadata.exceptions ?? [];
+  const gitignore = loadGitignore(resolvedRoot);
+
+  const currentFiles = selectFiles(
+    resolvedRoot,
+    includePatterns,
+    excludePatterns,
+    exceptions,
+    gitignore,
+  );
+
+  const currentHashes = Object.fromEntries(
+    currentFiles.map((filePath) => [
+      path.relative(resolvedRoot, filePath).replaceAll(path.sep, '/'),
+      computeFileHash(filePath),
+    ]),
+  );
+
+  const savedHashes = state.file_hashes ?? {};
+  const currentPaths = new Set(Object.keys(currentHashes));
+  const savedPaths = new Set(Object.keys(savedHashes));
+
+  const added = [...currentPaths]
+    .filter((filePath) => !savedPaths.has(filePath))
+    .sort();
+  const removed = [...savedPaths]
+    .filter((filePath) => !currentPaths.has(filePath))
+    .sort();
+  const modified = [...currentPaths]
+    .filter((filePath) => savedPaths.has(filePath))
+    .filter((filePath) => currentHashes[filePath] !== savedHashes[filePath])
+    .sort();
+
+  if (!added.length && !removed.length && !modified.length) {
+    console.log('No changes detected.');
+    return 0;
+  }
+
+  if (added.length) {
+    console.log(`\n${added.length} added:`);
+    for (const filePath of added) console.log(`  + ${filePath}`);
+  }
+
+  if (removed.length) {
+    console.log(`\n${removed.length} removed:`);
+    for (const filePath of removed) console.log(`  - ${filePath}`);
+  }
+
+  if (modified.length) {
+    console.log(`\n${modified.length} modified:`);
+    for (const filePath of modified) console.log(`  ~ ${filePath}`);
+  }
+
+  const affectedFolders = new Set(['.']);
+  for (const filePath of [...added, ...removed, ...modified]) {
+    const parts = filePath.split('/').slice(0, -1);
+    for (let i = 0; i < parts.length; i++) {
+      affectedFolders.add(parts.slice(0, i + 1).join('/'));
+    }
+  }
+
+  const sortedFolders = [...affectedFolders].sort();
+  console.log(`\n${sortedFolders.length} folders affected:`);
+  for (const folder of sortedFolders) {
+    console.log(`  ${folder}/`);
+  }
+
+  return 0;
+}
+
+export function cmdUpdate({ root }) {
+  const resolvedRoot = path.resolve(root);
+  const state = loadState(resolvedRoot);
+  if (!state) {
+    console.error("No codemap state found. Run 'init' first.");
+    return 1;
+  }
+
+  const metadata = state.metadata ?? {};
+  const includePatterns = metadata.include_patterns ?? ['**/*'];
+  const excludePatterns = metadata.exclude_patterns ?? [];
+  const exceptions = metadata.exceptions ?? [];
+  const gitignore = loadGitignore(resolvedRoot);
+
+  const selectedFiles = selectFiles(
+    resolvedRoot,
+    includePatterns,
+    excludePatterns,
+    exceptions,
+    gitignore,
+  );
+
+  const { state: nextState } = buildState(
+    resolvedRoot,
+    includePatterns,
+    excludePatterns,
+    exceptions,
+    selectedFiles,
+  );
+
+  saveState(resolvedRoot, nextState);
+  console.log(
+    `Updated ${STATE_DIR}/${STATE_FILE} with ${selectedFiles.length} files`,
+  );
+  return 0;
+}
+
+export function parseArgs(argv) {
+  const [command, ...rest] = argv;
+  const options = { include: [], exclude: [], exception: [] };
+
+  for (let i = 0; i < rest.length; i++) {
+    const arg = rest[i];
+    const value = rest[i + 1];
+
+    if (!arg?.startsWith('--')) continue;
+    if (value === undefined || value.startsWith('--')) {
+      throw new Error(`Missing value for ${arg}`);
+    }
+
+    const key = arg.slice(2);
+    if (key === 'include' || key === 'exclude' || key === 'exception') {
+      options[key].push(value);
+    } else if (key === 'root') {
+      options.root = value;
+    } else {
+      throw new Error(`Unknown option: ${arg}`);
+    }
+
+    i++;
+  }
+
+  return { command, options };
+}
+
+export function main(argv = process.argv.slice(2)) {
+  try {
+    const { command, options } = parseArgs(argv);
+
+    if (!command || !options.root) {
+      console.error(
+        'Usage: cartographer.mjs <init|changes|update> --root /path [--include glob] [--exclude glob] [--exception path]',
+      );
+      return 1;
+    }
+
+    if (command === 'init') return cmdInit(options);
+    if (command === 'changes') return cmdChanges(options);
+    if (command === 'update') return cmdUpdate(options);
+
+    console.error(`Unknown command: ${command}`);
+    return 1;
+  } catch (error) {
+    console.error(error instanceof Error ? error.message : String(error));
+    return 1;
+  }
+}
+
+const currentFilePath = fileURLToPath(import.meta.url);
+if (process.argv[1] && path.resolve(process.argv[1]) === currentFilePath) {
+  process.exit(main());
+}

+ 0 - 441
src/skills/codemap/scripts/cartographer.py

@@ -1,441 +0,0 @@
-#!/usr/bin/env python3
-"""
-Codemap cartographer - repository mapping and change detection tool.
-
-Commands:
-  init     Initialize mapping (create hashes + empty codemaps)
-  changes  Show what changed (read-only, like git status)
-  update   Update hashes (like git commit)
-
-Usage:
-  cartographer.py init --root /path/to/repo --include "src/**/*.ts" --exclude "node_modules/**"
-  cartographer.py changes --root /path/to/repo
-  cartographer.py update --root /path/to/repo
-"""
-
-import argparse
-import hashlib
-import json
-import os
-import re
-import sys
-from datetime import datetime, timezone
-from pathlib import Path
-from typing import Dict, List, Optional, Set
-
-VERSION = "1.0.0"
-STATE_DIR = ".slim"
-STATE_FILE = "codemap.json"
-LEGACY_STATE_FILE = "cartography.json"
-CODEMAP_FILE = "codemap.md"
-
-
-def load_gitignore(root: Path) -> List[str]:
-    """Load .gitignore patterns from the repository root."""
-    gitignore_path = root / ".gitignore"
-    patterns = []
-    if gitignore_path.exists():
-        with open(gitignore_path, "r", encoding="utf-8") as f:
-            for line in f:
-                line = line.strip()
-                if line and not line.startswith("#"):
-                    patterns.append(line)
-    return patterns
-
-
-class PatternMatcher:
-    """Efficiently match paths against multiple glob patterns using pre-compiled regex."""
-
-    def __init__(self, patterns: List[str]):
-        if not patterns:
-            self.regex = None
-            return
-
-        regex_parts = []
-        for pattern in patterns:
-            reg = re.escape(pattern)
-            reg = reg.replace(r'\*\*/', '(?:.*/)?')
-            reg = reg.replace(r'\*\*', '.*')
-            reg = reg.replace(r'\*', '[^/]*')
-            reg = reg.replace(r'\?', '.')
-
-            if pattern.endswith('/'):
-                reg += '.*'
-
-            if pattern.startswith('/'):
-                reg = '^' + reg[1:]
-            else:
-                reg = '(?:^|.*/)' + reg
-
-            regex_parts.append(f'(?:{reg}$)')
-
-        combined_regex = '|'.join(regex_parts)
-        self.regex = re.compile(combined_regex)
-
-    def matches(self, path: str) -> bool:
-        """Check if a path matches any of the patterns."""
-        if not self.regex:
-            return False
-        return bool(self.regex.search(path))
-
-
-def select_files(
-    root: Path,
-    include_patterns: List[str],
-    exclude_patterns: List[str],
-    exceptions: List[str],
-    gitignore_patterns: List[str],
-) -> List[Path]:
-    """Select files based on include/exclude patterns and exceptions."""
-    selected = []
-
-    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):
-        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:]
-
-            if gitignore_matcher.matches(rel_path):
-                continue
-
-            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)
-
-
-def compute_file_hash(filepath: Path) -> str:
-    """Compute MD5 hash of file content."""
-    hasher = hashlib.md5()
-    try:
-        with open(filepath, "rb") as f:
-            for chunk in iter(lambda: f.read(8192), b""):
-                hasher.update(chunk)
-        return hasher.hexdigest()
-    except (IOError, OSError):
-        return ""
-
-
-def compute_folder_hash(folder: str, file_hashes: Dict[str, str]) -> str:
-    """Compute a stable hash for a folder based on its files."""
-    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 ""
-
-    hasher = hashlib.md5()
-    for path, hash_val in folder_files:
-        hasher.update(f"{path}:{hash_val}\n".encode())
-    return hasher.hexdigest()
-
-
-def get_folders_with_files(files: List[Path], root: Path) -> Set[str]:
-    """Get all unique folders that contain selected files."""
-    folders = set()
-    for f in files:
-        rel = f.relative_to(root)
-        parts = rel.parts[:-1]
-        for i in range(len(parts)):
-            folders.add("/".join(parts[: i + 1]))
-    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 codemap state, migrating legacy state if needed."""
-    migrate_legacy_state(root)
-    state_path = root / STATE_DIR / STATE_FILE
-    if state_path.exists():
-        try:
-            with open(state_path, "r", encoding="utf-8") as f:
-                return json.load(f)
-        except (json.JSONDecodeError, IOError):
-            return None
-    return None
-
-
-def save_state(root: Path, state: dict) -> None:
-    """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)
-
-
-def create_empty_codemap(folder_path: Path, folder_name: str) -> None:
-    """Create an empty codemap.md file with a header."""
-    codemap_path = folder_path / CODEMAP_FILE
-    if not codemap_path.exists():
-        content = f"""# {folder_name}/
-
-<!-- Fixer: Fill in this section with architectural understanding -->
-
-## Responsibility
-
-<!-- What is this folder's job in the system? -->
-
-## Design
-
-<!-- Key patterns, abstractions, architectural decisions -->
-
-## Flow
-
-<!-- How does data/control flow through this module? -->
-
-## Integration
-
-<!-- How does it connect to other parts of the system? -->
-"""
-        with open(codemap_path, "w", encoding="utf-8") as f:
-            f.write(content)
-
-
-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
-
-    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}")
-
-    selected_files = select_files(
-        root, include_patterns, exclude_patterns, exceptions, gitignore
-    )
-
-    print(f"Selected {len(selected_files)} files")
-
-    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)
-
-    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)
-
-    state = {
-        "metadata": {
-            "version": VERSION,
-            "last_run": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
-            "root": str(root),
-            "include_patterns": include_patterns,
-            "exclude_patterns": exclude_patterns,
-            "exceptions": exceptions,
-        },
-        "file_hashes": file_hashes,
-        "folder_hashes": folder_hashes,
-    }
-
-    save_state(root, state)
-    print(f"Created {STATE_DIR}/{STATE_FILE}")
-
-    for folder in folders:
-        if folder == ".":
-            folder_path = root
-            folder_name = root.name
-        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 codemap state found. Run 'init' first.", file=sys.stderr)
-        return 1
-
-    metadata = state.get("metadata", {})
-    include_patterns = metadata.get("include_patterns", ["**/*"])
-    exclude_patterns = metadata.get("exclude_patterns", [])
-    exceptions = metadata.get("exceptions", [])
-
-    gitignore = load_gitignore(root)
-
-    current_files = select_files(
-        root, include_patterns, exclude_patterns, exceptions, gitignore
-    )
-
-    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", {})
-
-    added = set(current_hashes.keys()) - set(saved_hashes.keys())
-    removed = set(saved_hashes.keys()) - set(current_hashes.keys())
-    modified = {
-        path
-        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}")
-
-    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 codemap state found. Run 'init' first.", file=sys.stderr)
-        return 1
-
-    metadata = state.get("metadata", {})
-    include_patterns = metadata.get("include_patterns", ["**/*"])
-    exclude_patterns = metadata.get("exclude_patterns", [])
-    exceptions = metadata.get("exceptions", [])
-
-    gitignore = load_gitignore(root)
-
-    selected_files = select_files(
-        root, include_patterns, exclude_patterns, exceptions, gitignore
-    )
-
-    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)
-
-    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)
-
-    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="Codemap cartographer - repository mapping and change detection"
-    )
-    subparsers = parser.add_subparsers(dest="command", help="Available commands")
-
-    init_parser = subparsers.add_parser("init", help="Initialize mapping")
-    init_parser.add_argument("--root", required=True, help="Repository root path")
-    init_parser.add_argument(
-        "--include", action="append", help="Glob patterns for files to include"
-    )
-    init_parser.add_argument(
-        "--exclude", action="append", help="Glob patterns for files to exclude"
-    )
-    init_parser.add_argument(
-        "--exception", action="append", help="Explicit file paths to include despite exclusions"
-    )
-
-    changes_parser = subparsers.add_parser("changes", help="Show what changed")
-    changes_parser.add_argument("--root", required=True, help="Repository root path")
-
-    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":
-        return cmd_changes(args)
-    elif args.command == "update":
-        return cmd_update(args)
-    else:
-        parser.print_help()
-        return 1
-
-
-if __name__ == "__main__":
-    sys.exit(main())

+ 127 - 0
src/skills/codemap/scripts/cartographer.test.ts

@@ -0,0 +1,127 @@
+import { afterEach, describe, expect, test } from 'bun:test';
+import {
+  existsSync,
+  mkdirSync,
+  mkdtempSync,
+  readFileSync,
+  rmSync,
+  writeFileSync,
+} from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+
+import {
+  computeFileHash,
+  computeFolderHash,
+  loadState,
+  PatternMatcher,
+  selectFiles,
+} from './cartographer.mjs';
+
+const tempDirs: string[] = [];
+
+function createTempDir() {
+  const dir = mkdtempSync(path.join(os.tmpdir(), 'codemap-'));
+  tempDirs.push(dir);
+  return dir;
+}
+
+afterEach(() => {
+  for (const dir of tempDirs.splice(0)) {
+    rmSync(dir, { force: true, recursive: true });
+  }
+});
+
+describe('PatternMatcher', () => {
+  test('matches expected paths', () => {
+    const matcher = new PatternMatcher([
+      'node_modules/',
+      'dist/',
+      '*.log',
+      'src/**/*.ts',
+    ]);
+
+    expect(matcher.matches('node_modules/foo.js')).toBe(true);
+    expect(matcher.matches('vendor/node_modules/bar.js')).toBe(true);
+    expect(matcher.matches('dist/main.js')).toBe(true);
+    expect(matcher.matches('src/dist/output.js')).toBe(true);
+    expect(matcher.matches('error.log')).toBe(true);
+    expect(matcher.matches('logs/access.log')).toBe(true);
+    expect(matcher.matches('src/index.ts')).toBe(true);
+    expect(matcher.matches('src/utils/helper.ts')).toBe(true);
+    expect(matcher.matches('README.md')).toBe(false);
+    expect(matcher.matches('tests/test.py')).toBe(false);
+  });
+});
+
+describe('hash helpers', () => {
+  test('computes file hash', () => {
+    const dir = createTempDir();
+    const filePath = path.join(dir, 'file.txt');
+    writeFileSync(filePath, 'test content');
+
+    expect(computeFileHash(filePath)).toBe('9473fdd0d880a43c21b7778d34872157');
+  });
+
+  test('computes stable folder hash', () => {
+    const fileHashes = {
+      'src/a.ts': 'hash-a',
+      'src/b.ts': 'hash-b',
+      'tests/test.ts': 'hash-test',
+    };
+
+    const hash1 = computeFolderHash('src', fileHashes);
+    const hash2 = computeFolderHash('src', fileHashes);
+    const hash3 = computeFolderHash('src', {
+      'src/a.ts': 'hash-a-modified',
+      'src/b.ts': 'hash-b',
+    });
+
+    expect(hash1).toBe(hash2);
+    expect(hash1).not.toBe(hash3);
+  });
+});
+
+describe('selectFiles', () => {
+  test('respects include and exclude patterns', () => {
+    const root = createTempDir();
+    mkdirSync(path.join(root, 'src'));
+    mkdirSync(path.join(root, 'node_modules'));
+    writeFileSync(path.join(root, 'src', 'index.ts'), 'code');
+    writeFileSync(path.join(root, 'src', 'index.test.ts'), 'test');
+    writeFileSync(path.join(root, 'node_modules', 'foo.js'), 'dep');
+    writeFileSync(path.join(root, 'package.json'), '{}');
+
+    const selected = selectFiles(
+      root,
+      ['src/**/*.ts', 'package.json'],
+      ['**/*.test.ts', 'node_modules/'],
+      [],
+      [],
+    ).map((filePath) =>
+      path.relative(root, filePath).replaceAll(path.sep, '/'),
+    );
+
+    expect(selected).toEqual(['package.json', 'src/index.ts']);
+  });
+});
+
+describe('loadState', () => {
+  test('migrates legacy cartography state', () => {
+    const root = createTempDir();
+    const slimDir = path.join(root, '.slim');
+    mkdirSync(slimDir);
+
+    const legacyState = { metadata: { version: '1.0.0' } };
+    writeFileSync(
+      path.join(slimDir, 'cartography.json'),
+      JSON.stringify(legacyState),
+    );
+
+    expect(loadState(root)).toEqual(legacyState);
+    expect(existsSync(path.join(slimDir, 'cartography.json'))).toBe(false);
+    expect(
+      JSON.parse(readFileSync(path.join(slimDir, 'codemap.json'), 'utf8')),
+    ).toEqual(legacyState);
+  });
+});

+ 0 - 100
src/skills/codemap/scripts/test_cartographer.py

@@ -1,100 +0,0 @@
-import hashlib
-import json
-import os
-import tempfile
-import unittest
-from pathlib import Path
-
-from cartographer import (PatternMatcher, compute_file_hash,
-                          compute_folder_hash, load_state, select_files)
-
-
-class TestCartographer(unittest.TestCase):
-    def test_pattern_matcher(self):
-        patterns = ["node_modules/", "dist/", "*.log", "src/**/*.ts"]
-        matcher = PatternMatcher(patterns)
-
-        self.assertTrue(matcher.matches("node_modules/foo.js"))
-        self.assertTrue(matcher.matches("vendor/node_modules/bar.js"))
-        self.assertTrue(matcher.matches("dist/main.js"))
-        self.assertTrue(matcher.matches("src/dist/output.js"))
-
-        self.assertTrue(matcher.matches("error.log"))
-        self.assertTrue(matcher.matches("logs/access.log"))
-
-        self.assertTrue(matcher.matches("src/index.ts"))
-        self.assertTrue(matcher.matches("src/utils/helper.ts"))
-
-        self.assertFalse(matcher.matches("README.md"))
-        self.assertFalse(matcher.matches("tests/test.py"))
-
-    def test_compute_file_hash(self):
-        with tempfile.NamedTemporaryFile(mode="wb", delete=False) as f:
-            f.write(b"test content")
-            f_path = f.name
-
-        try:
-            h1 = compute_file_hash(Path(f_path))
-            expected = hashlib.md5(b"test content").hexdigest()
-            self.assertEqual(h1, expected)
-            self.assertEqual(h1, "9473fdd0d880a43c21b7778d34872157")
-        finally:
-            if os.path.exists(f_path):
-                os.unlink(f_path)
-
-    def test_compute_folder_hash(self):
-        file_hashes = {
-            "src/a.ts": "hash-a",
-            "src/b.ts": "hash-b",
-            "tests/test.ts": "hash-test"
-        }
-
-        h1 = compute_folder_hash("src", file_hashes)
-        h2 = compute_folder_hash("src", file_hashes)
-        self.assertEqual(h1, h2)
-
-        file_hashes_alt = {
-            "src/a.ts": "hash-a-modified",
-            "src/b.ts": "hash-b"
-        }
-        h3 = compute_folder_hash("src", file_hashes_alt)
-        self.assertNotEqual(h1, h3)
-
-    def test_select_files(self):
-        with tempfile.TemporaryDirectory() as tmpdir:
-            root = Path(tmpdir)
-            (root / "src").mkdir()
-            (root / "node_modules").mkdir()
-            (root / "src" / "index.ts").write_text("code")
-            (root / "src" / "index.test.ts").write_text("test")
-            (root / "node_modules" / "foo.js").write_text("dep")
-            (root / "package.json").write_text("{}")
-
-            includes = ["src/**/*.ts", "package.json"]
-            excludes = ["**/*.test.ts", "node_modules/"]
-            exceptions = []
-
-            selected = select_files(root, includes, excludes, exceptions, [])
-
-            rel_selected = sorted([os.path.relpath(f, root) for f in selected])
-            self.assertEqual(rel_selected, ["package.json", "src/index.ts"])
-
-    def test_load_state_migrates_legacy_cartography_json(self):
-        with tempfile.TemporaryDirectory() as tmpdir:
-            root = Path(tmpdir)
-            slim_dir = root / ".slim"
-            slim_dir.mkdir()
-
-            legacy_path = slim_dir / "cartography.json"
-            legacy_state = {"metadata": {"version": "1.0.0"}}
-            legacy_path.write_text(json.dumps(legacy_state), encoding="utf-8")
-
-            loaded = load_state(root)
-
-            self.assertEqual(loaded, legacy_state)
-            self.assertFalse(legacy_path.exists())
-            self.assertTrue((slim_dir / "codemap.json").exists())
-
-
-if __name__ == "__main__":
-    unittest.main()