|
| 1 | +#!/usr/bin/env python |
| 2 | +"""_index_identity.py — canonical project-root resolver and codemap index identity. |
| 3 | +
|
| 4 | +Every codemap entrypoint (launchers, skills, both runtimes) resolves the shared |
| 5 | +project index through this module so Claude Code, Codex, and direct CLI use on the |
| 6 | +same real project root land on one runtime-neutral index. Runtime/session identity |
| 7 | +is never part of the resolved path — only the logging subtree is runtime-scoped |
| 8 | +(see ``_runtime_log``). |
| 9 | +
|
| 10 | +Resolution rules (plan §4.4 "Shared project index"): |
| 11 | +
|
| 12 | +- the canonical root is the git repository root (else the current directory), |
| 13 | + with symlinks/case aliases collapsed so an alias of the same project resolves |
| 14 | + to the same identity; |
| 15 | +- the default index is ``<canonical-root>/.cache/codemap/<project>.json`` with a |
| 16 | + sibling ``.index-rw/`` coordination directory; |
| 17 | +- ``CODEMAP_INDEX_DIR`` is a product-wide base override (never a runtime override). |
| 18 | + Its target is ``<override>/<root-key>/<project>.json`` where ``<root-key>`` is the |
| 19 | + full lowercase SHA-256 of the normalized canonical-root identity, so equal-basename |
| 20 | + projects get distinct reusable indexes; |
| 21 | +- a legacy flat override at ``<override>/<project>.json`` is a read-only compatibility |
| 22 | + candidate only when its stored ``scan_root`` matches the canonical root; a mismatch |
| 23 | + is ignored with an ``index_root_collision`` diagnostic and never blocks the |
| 24 | + root-keyed target; |
| 25 | +- ``split_index_roots`` is reported when two environments resolve different index paths. |
| 26 | +""" |
| 27 | + |
| 28 | +from __future__ import annotations |
| 29 | + |
| 30 | +import hashlib |
| 31 | +import json |
| 32 | +import os |
| 33 | +import shutil |
| 34 | +import subprocess |
| 35 | +from dataclasses import dataclass, field |
| 36 | +from pathlib import Path |
| 37 | + |
| 38 | +_GIT_TIMEOUT_S = 5 |
| 39 | +INDEX_SUBDIR = Path(".cache", "codemap") |
| 40 | +COORDINATION_DIRNAME = ".index-rw" |
| 41 | + |
| 42 | +INDEX_ROOT_COLLISION = "index_root_collision" |
| 43 | +SPLIT_INDEX_ROOTS = "split_index_roots" |
| 44 | + |
| 45 | +_UNSET = object() |
| 46 | + |
| 47 | + |
| 48 | +@dataclass(frozen=True) |
| 49 | +class Diagnostic: |
| 50 | + """A bounded, machine-readable resolver diagnostic. |
| 51 | +
|
| 52 | + Attributes: |
| 53 | + code: Stable diagnostic code (e.g. ``index_root_collision``). |
| 54 | + message: Human-readable one-line summary. |
| 55 | + detail: Structured supporting fields; never contains secrets. |
| 56 | + """ |
| 57 | + |
| 58 | + code: str |
| 59 | + message: str |
| 60 | + detail: dict = field(default_factory=dict) |
| 61 | + |
| 62 | + |
| 63 | +@dataclass(frozen=True) |
| 64 | +class IndexIdentity: |
| 65 | + """Resolved codemap index identity for one canonical project root. |
| 66 | +
|
| 67 | + Attributes: |
| 68 | + project: Canonical-root basename. |
| 69 | + root: Canonical (symlink-collapsed) project root. |
| 70 | + root_key: Full lowercase SHA-256 of the normalized root identity. |
| 71 | + index_dir: Directory holding the resolved index and coordination subtree. |
| 72 | + index_path: Resolved ``<project>.json`` index path. |
| 73 | + coordination_dir: Sibling ``.index-rw/`` directory beside the index. |
| 74 | + override: ``True`` when ``CODEMAP_INDEX_DIR`` selected the base. |
| 75 | + legacy_candidate: Read-only legacy flat index path when it matches this |
| 76 | + root, else ``None``. |
| 77 | + diagnostics: Any diagnostics raised while resolving (e.g. collisions). |
| 78 | + """ |
| 79 | + |
| 80 | + project: str |
| 81 | + root: Path |
| 82 | + root_key: str |
| 83 | + index_dir: Path |
| 84 | + index_path: Path |
| 85 | + coordination_dir: Path |
| 86 | + override: bool |
| 87 | + legacy_candidate: Path | None |
| 88 | + diagnostics: tuple[Diagnostic, ...] |
| 89 | + |
| 90 | + |
| 91 | +def _real(path: Path) -> Path: |
| 92 | + """Return *path* with symlinks and ``..`` collapsed (best-effort).""" |
| 93 | + try: |
| 94 | + return path.resolve() |
| 95 | + except OSError: |
| 96 | + return Path(os.path.abspath(path)) |
| 97 | + |
| 98 | + |
| 99 | +def canonical_root(cwd: Path | str | None = None) -> Path: |
| 100 | + """Return the canonical project root for *cwd*. |
| 101 | +
|
| 102 | + The root is the git repository top-level when *cwd* is inside a repository, |
| 103 | + otherwise *cwd* itself; either way the result is symlink-collapsed so an alias |
| 104 | + of the same project resolves to the same identity. |
| 105 | +
|
| 106 | + Args: |
| 107 | + cwd: Working directory to resolve from (defaults to the process CWD). |
| 108 | +
|
| 109 | + Returns: |
| 110 | + Absolute, symlink-collapsed canonical root path. |
| 111 | +
|
| 112 | + Examples: |
| 113 | + >>> isinstance(canonical_root(), Path) # doctest: +SKIP |
| 114 | + True |
| 115 | + """ |
| 116 | + work = Path(cwd) if cwd is not None else Path.cwd() |
| 117 | + git = shutil.which("git") |
| 118 | + if git: |
| 119 | + try: |
| 120 | + out = subprocess.run( |
| 121 | + [git, "rev-parse", "--show-toplevel"], |
| 122 | + capture_output=True, |
| 123 | + text=True, |
| 124 | + cwd=str(work), |
| 125 | + check=True, |
| 126 | + timeout=_GIT_TIMEOUT_S, |
| 127 | + ).stdout.strip() |
| 128 | + if out: |
| 129 | + return _real(Path(out)) |
| 130 | + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError): |
| 131 | + pass |
| 132 | + return _real(work) |
| 133 | + |
| 134 | + |
| 135 | +def normalize_identity(root: Path | str, *, windows: bool | None = None) -> str: |
| 136 | + """Return the normalized string identity used to key an index for *root*. |
| 137 | +
|
| 138 | + On Windows the identity casefolds and normalizes separators so drive-letter |
| 139 | + form and case differences collapse to one identity; on POSIX the resolved |
| 140 | + path string is already the identity. |
| 141 | +
|
| 142 | + Args: |
| 143 | + root: Canonical root path (should already be symlink-collapsed). |
| 144 | + windows: Force Windows normalization; defaults to the host platform. |
| 145 | +
|
| 146 | + Returns: |
| 147 | + The normalized identity string. |
| 148 | +
|
| 149 | + Examples: |
| 150 | + >>> normalize_identity("/Repo/Proj", windows=False) |
| 151 | + '/Repo/Proj' |
| 152 | + >>> normalize_identity("C:/Repo/Proj", windows=True) |
| 153 | + 'c:\\\\repo\\\\proj' |
| 154 | + """ |
| 155 | + if windows is None: |
| 156 | + windows = os.name == "nt" |
| 157 | + raw = str(root) |
| 158 | + if windows: |
| 159 | + raw = raw.replace("/", "\\").casefold() |
| 160 | + return raw |
| 161 | + |
| 162 | + |
| 163 | +def root_key(root: Path | str, *, windows: bool | None = None) -> str: |
| 164 | + """Return the full lowercase SHA-256 of the normalized identity of *root*. |
| 165 | +
|
| 166 | + Args: |
| 167 | + root: Canonical root path. |
| 168 | + windows: Force Windows normalization; defaults to the host platform. |
| 169 | +
|
| 170 | + Returns: |
| 171 | + 64-character lowercase hex digest, stable and free of any raw path. |
| 172 | +
|
| 173 | + Examples: |
| 174 | + >>> len(root_key("/repo/proj", windows=False)) |
| 175 | + 64 |
| 176 | + >>> root_key("/a", windows=False) == root_key("/a", windows=False) |
| 177 | + True |
| 178 | + """ |
| 179 | + identity = normalize_identity(root, windows=windows) |
| 180 | + return hashlib.sha256(identity.encode("utf-8")).hexdigest() |
| 181 | + |
| 182 | + |
| 183 | +def _read_scan_root(path: Path) -> str | None: |
| 184 | + """Return the ``scan_root`` field stored in *path*, or ``None`` if unreadable.""" |
| 185 | + try: |
| 186 | + with path.open(encoding="utf-8") as fh: |
| 187 | + data = json.load(fh) |
| 188 | + except (OSError, ValueError): |
| 189 | + return None |
| 190 | + value = data.get("scan_root") if isinstance(data, dict) else None |
| 191 | + return value if isinstance(value, str) else None |
| 192 | + |
| 193 | + |
| 194 | +def _legacy_candidate(path: Path, root: Path, diagnostics: list[Diagnostic]) -> Path | None: |
| 195 | + """Return *path* when it is a valid read-only legacy candidate for *root*. |
| 196 | +
|
| 197 | + A legacy flat override index is reusable only when its stored ``scan_root`` |
| 198 | + normalizes to the same identity as *root*; a mismatch appends an |
| 199 | + ``index_root_collision`` diagnostic and is never returned. |
| 200 | + """ |
| 201 | + if not path.is_file(): |
| 202 | + return None |
| 203 | + stored = _read_scan_root(path) |
| 204 | + if stored is not None and normalize_identity(_real(Path(stored))) == normalize_identity(root): |
| 205 | + return path |
| 206 | + diagnostics.append( |
| 207 | + Diagnostic( |
| 208 | + INDEX_ROOT_COLLISION, |
| 209 | + "legacy flat index does not match the canonical root; ignoring it", |
| 210 | + {"legacy_path": str(path), "stored_scan_root": stored, "canonical_root": str(root)}, |
| 211 | + ) |
| 212 | + ) |
| 213 | + return None |
| 214 | + |
| 215 | + |
| 216 | +def resolve_index( |
| 217 | + cwd: Path | str | None = None, |
| 218 | + *, |
| 219 | + root: Path | str | None = None, |
| 220 | + index_dir_override: object = _UNSET, |
| 221 | +) -> IndexIdentity: |
| 222 | + """Resolve the canonical codemap index identity. |
| 223 | +
|
| 224 | + Args: |
| 225 | + cwd: Working directory used to derive the canonical root (ignored when |
| 226 | + *root* is given). |
| 227 | + root: Explicit canonical root; when omitted it is derived from *cwd*. |
| 228 | + index_dir_override: Base override path. ``_UNSET`` (default) reads |
| 229 | + ``CODEMAP_INDEX_DIR``; pass ``None`` to force the default layout even |
| 230 | + when the environment variable is set. |
| 231 | +
|
| 232 | + Returns: |
| 233 | + An :class:`IndexIdentity` describing the resolved paths and diagnostics. |
| 234 | +
|
| 235 | + Examples: |
| 236 | + >>> ident = resolve_index() # doctest: +SKIP |
| 237 | + >>> ident.index_path.name.endswith(".json") # doctest: +SKIP |
| 238 | + True |
| 239 | + """ |
| 240 | + base_root = _real(Path(root)) if root is not None else canonical_root(cwd) |
| 241 | + project = base_root.name |
| 242 | + rk = root_key(base_root) |
| 243 | + override_raw = os.environ.get("CODEMAP_INDEX_DIR") if index_dir_override is _UNSET else index_dir_override |
| 244 | + diagnostics: list[Diagnostic] = [] |
| 245 | + |
| 246 | + if override_raw: |
| 247 | + override_base = Path(str(override_raw)).expanduser().resolve() |
| 248 | + index_dir = override_base / rk |
| 249 | + legacy = _legacy_candidate(override_base / f"{project}.json", base_root, diagnostics) |
| 250 | + override = True |
| 251 | + else: |
| 252 | + index_dir = base_root / INDEX_SUBDIR |
| 253 | + legacy = None |
| 254 | + override = False |
| 255 | + |
| 256 | + index_path = index_dir / f"{project}.json" |
| 257 | + return IndexIdentity( |
| 258 | + project=project, |
| 259 | + root=base_root, |
| 260 | + root_key=rk, |
| 261 | + index_dir=index_dir, |
| 262 | + index_path=index_path, |
| 263 | + coordination_dir=index_dir / COORDINATION_DIRNAME, |
| 264 | + override=override, |
| 265 | + legacy_candidate=legacy, |
| 266 | + diagnostics=tuple(diagnostics), |
| 267 | + ) |
| 268 | + |
| 269 | + |
| 270 | +def diagnose_split_index_roots(path_a: Path, path_b: Path) -> Diagnostic | None: |
| 271 | + """Return a ``split_index_roots`` diagnostic when two paths disagree. |
| 272 | +
|
| 273 | + ``codemap-py integrate check`` uses this to report — never to reconcile — |
| 274 | + when two runtime environments resolve different index paths. |
| 275 | +
|
| 276 | + Args: |
| 277 | + path_a: Index path resolved in the first environment. |
| 278 | + path_b: Index path resolved in the second environment. |
| 279 | +
|
| 280 | + Returns: |
| 281 | + A :class:`Diagnostic` when the paths differ, else ``None``. |
| 282 | +
|
| 283 | + Examples: |
| 284 | + >>> diagnose_split_index_roots(Path("/a/i.json"), Path("/a/i.json")) is None |
| 285 | + True |
| 286 | + >>> diagnose_split_index_roots(Path("/a/i.json"), Path("/b/i.json")).code |
| 287 | + 'split_index_roots' |
| 288 | + """ |
| 289 | + if path_a == path_b: |
| 290 | + return None |
| 291 | + return Diagnostic( |
| 292 | + SPLIT_INDEX_ROOTS, |
| 293 | + "runtime environments resolve different index paths; not reconciling", |
| 294 | + {"path_a": str(path_a), "path_b": str(path_b)}, |
| 295 | + ) |
0 commit comments