|
2 | 2 | """Resolve CODEMAP_ENABLED from auto/strict/off to true/false. |
3 | 3 |
|
4 | 4 | Python (not shell) because plugins must run on Windows, where a `#!` shebang is |
5 | | -not honoured — see plugins/CLAUDE.md §Installability. Behaviour is a 1:1 port of |
6 | | -the former `bin/codemap-resolve` bash script; differential parity was verified |
7 | | -across the full mode/index matrix before the shell version was removed. |
| 5 | +not honoured — see plugins/CLAUDE.md §Installability. |
| 6 | +
|
| 7 | +Index location mirrors codemap-py's own resolver (``codemap_py.index_paths.resolve_index``, |
| 8 | +consumed by ``query.find_index``) exactly: |
| 9 | +
|
| 10 | +* the project root is the **git toplevel**, or the CWD when outside a repository; |
| 11 | +* the index directory defaults to ``<root>/.cache/codemap`` — anchored to the root, never |
| 12 | + to the process CWD, so a skill invoked from a subdirectory still finds the index instead |
| 13 | + of reporting a false ``no_index``; |
| 14 | +* ``CODEMAP_INDEX_DIR`` overrides that directory as a flat ``<override>/<project>.json``; |
| 15 | +* the project name is the **raw** directory basename with no sanitization. The scanner |
| 16 | + writes that name verbatim, so stripping characters here made every repository whose |
| 17 | + directory contains a space, ``+`` or a non-ASCII character resolve to a filename the |
| 18 | + scanner never writes — a permanent, silent ``no_index``. |
| 19 | +
|
| 20 | +Also writes currency status to ``${TMPDIR}/<prefix>-${CSID}`` ("current", "stale", "off" |
| 21 | +or "no_index"), where ``<prefix>`` is supplied by the calling plugin via |
| 22 | +``--currency-prefix``. |
| 23 | +
|
| 24 | +This file is kept **byte-identical** across consuming plugins by |
| 25 | +``plugins/cc_foundry/bin/propagate_shared.py`` (MANIFEST). Every per-plugin difference |
| 26 | +must therefore arrive as an argument from that plugin's own wrapper — never as an edit |
| 27 | +here. |
| 28 | +
|
| 29 | +Usage: |
| 30 | + codemap_resolve.py [auto|strict|off|true|false] [--currency-prefix PREFIX] |
8 | 31 |
|
9 | | -Also writes currency status to ${TMPDIR}/dev-codemap-currency-${CSID} when |
10 | | -check-index-currency is available ("current", "stale", or "no_index"). |
11 | | -Usage: CODEMAP_ENABLED=$(python .../codemap_resolve.py "$CODEMAP_ENABLED") || exit 1 |
12 | 32 | Exits 0: prints "true" or "false" to stdout |
13 | 33 | Exits 1: strict mode and binary/index missing — error to stderr |
14 | 34 | """ |
15 | 35 |
|
| 36 | +import argparse |
| 37 | +import json |
16 | 38 | import os |
17 | | -import re |
18 | 39 | import shutil |
19 | 40 | import subprocess |
20 | 41 | import sys |
21 | 42 | import tempfile |
22 | 43 | from pathlib import Path |
23 | 44 |
|
24 | | -CURRENCY_PREFIX = "dev-codemap-currency" |
25 | | -TOOL_LABEL = "codemap-py" # warn prefix |
26 | | -QUERY_LABEL = "codemap-py" # binary name in the strict BREAKING message |
| 45 | +TOOL_LABEL = "codemap-py" # binary name, warn prefix, and plugin name — all one label |
| 46 | +DEFAULT_CURRENCY_PREFIX = "codemap-currency" |
| 47 | +_GIT_TIMEOUT_S = 5 |
| 48 | +_CURRENCY_TIMEOUT_S = 15 # currency check may walk the tree (tier 2); bounded, never unbounded |
27 | 49 |
|
28 | 50 |
|
29 | | -def _currency_file() -> Path: |
| 51 | +def _currency_file(prefix: str) -> Path: |
| 52 | + """Return the session-scoped currency sentinel path for *prefix*.""" |
30 | 53 | csid = os.environ.get("CSID") or os.environ.get("CLAUDE_CODE_SESSION_ID") or "shared" |
31 | 54 | tmp = os.environ.get("TMPDIR") or tempfile.gettempdir() |
32 | | - return Path(tmp) / f"{CURRENCY_PREFIX}-{csid}" |
| 55 | + return Path(tmp) / f"{prefix}-{csid}" |
33 | 56 |
|
34 | 57 |
|
35 | | -def _write_currency(value: str) -> None: |
| 58 | +def _write_currency(prefix: str, value: str) -> None: |
| 59 | + """Record *value* in the currency sentinel, ignoring write failures.""" |
36 | 60 | try: |
37 | | - _currency_file().write_text(value + "\n") |
| 61 | + _currency_file(prefix).write_text(value + "\n") |
38 | 62 | except OSError: |
39 | 63 | pass # currency status is advisory; never fail the gate on it |
40 | 64 |
|
41 | 65 |
|
42 | | -def _project_name() -> str: |
| 66 | +def _canonical_root() -> Path: |
| 67 | + """Return the project root — git toplevel, else CWD — symlink-collapsed. |
| 68 | +
|
| 69 | + Mirrors ``codemap_py.index_paths.canonical_root`` so consumer and provider agree on |
| 70 | + both the index directory and the project name. |
| 71 | + """ |
43 | 72 | try: |
44 | 73 | out = subprocess.run( |
45 | 74 | ["git", "rev-parse", "--show-toplevel"], |
46 | 75 | capture_output=True, |
47 | 76 | text=True, |
48 | | - timeout=5, |
| 77 | + timeout=_GIT_TIMEOUT_S, |
49 | 78 | check=False, |
50 | 79 | ) |
51 | | - root = out.stdout.strip() if out.returncode == 0 and out.stdout.strip() else "default" |
| 80 | + if out.returncode == 0 and out.stdout.strip(): |
| 81 | + return Path(out.stdout.strip()).resolve() |
52 | 82 | except (OSError, subprocess.SubprocessError): |
53 | | - root = "default" |
54 | | - return re.sub(r"[^a-zA-Z0-9._-]", "", Path(root).name) or "default" |
| 83 | + pass |
| 84 | + return Path.cwd().resolve() |
| 85 | + |
55 | 86 |
|
| 87 | +def _index_path(root: Path) -> Path: |
| 88 | + """Return the index file for *root*, honouring the flat ``CODEMAP_INDEX_DIR`` override.""" |
| 89 | + override = os.environ.get("CODEMAP_INDEX_DIR") |
| 90 | + index_dir = Path(override).expanduser().resolve() if override else root / ".cache" / "codemap" |
| 91 | + return index_dir / f"{root.name}.json" |
56 | 92 |
|
57 | | -def _currency_field(cic: str, index: Path, field: str, fallback: str) -> str: |
| 93 | + |
| 94 | +def _currency(cic: str, index: Path) -> tuple[str, str]: |
| 95 | + """Return ``(status, reason)`` from a single, timed ``check-index-currency`` run. |
| 96 | +
|
| 97 | + ``check-index-currency`` signals the verdict through its **exit code** (0 current, |
| 98 | + 1 stale, 2 no_index) while always printing the full JSON result to stdout. Gating on |
| 99 | + ``returncode == 0`` therefore discarded every stale verdict and substituted the |
| 100 | + "current" fallback, so the stale gate could never fire. The verdict is read from |
| 101 | + stdout and the exit code is deliberately ignored. |
| 102 | +
|
| 103 | + One run yields both fields; querying ``--field status`` and ``--field reason`` |
| 104 | + separately spawned two interpreters, and neither call was time-bounded. |
| 105 | + """ |
58 | 106 | try: |
59 | 107 | out = subprocess.run( |
60 | | - [sys.executable, cic, "--index-path", str(index), "--field", field], |
| 108 | + [sys.executable, cic, "--index-path", str(index)], |
61 | 109 | capture_output=True, |
62 | 110 | text=True, |
| 111 | + timeout=_CURRENCY_TIMEOUT_S, |
63 | 112 | check=False, |
64 | 113 | ) |
65 | | - return out.stdout.strip() if out.returncode == 0 else fallback |
66 | | - except (OSError, subprocess.SubprocessError): |
67 | | - return fallback |
| 114 | + data = json.loads(out.stdout.strip()) |
| 115 | + except (OSError, subprocess.SubprocessError, ValueError): |
| 116 | + return "current", "" # fail open: an unreadable verdict must not block the gate |
| 117 | + if not isinstance(data, dict): |
| 118 | + return "current", "" |
| 119 | + status = data.get("status") |
| 120 | + reason = data.get("reason") |
| 121 | + return (status if isinstance(status, str) and status else "current"), (reason if isinstance(reason, str) else "") |
| 122 | + |
| 123 | + |
| 124 | +def _record_currency(prefix: str, index: Path) -> None: |
| 125 | + """Probe index currency and record it, warning on stderr when the index is stale.""" |
| 126 | + cic = shutil.which("check-index-currency") |
| 127 | + if not cic: |
| 128 | + _write_currency(prefix, "current") |
| 129 | + return |
| 130 | + currency, reason = _currency(cic, index) |
| 131 | + _write_currency(prefix, currency) |
| 132 | + if currency == "stale": |
| 133 | + print( |
| 134 | + f"⚠ {TOOL_LABEL}: index is stale — {reason}\n Run /codemap-py:scan-codebase to refresh it.", |
| 135 | + file=sys.stderr, |
| 136 | + ) |
| 137 | + |
| 138 | + |
| 139 | +def _unavailable(prefix: str, mode: str, strict_detail: str, warning: str) -> int: |
| 140 | + """Record ``no_index`` and emit the strict or soft outcome; returns the exit code.""" |
| 141 | + _write_currency(prefix, "no_index") |
| 142 | + if mode == "strict": |
| 143 | + print(f"! BREAKING — --codemap strict: {strict_detail}", file=sys.stderr) |
| 144 | + return 1 |
| 145 | + if warning: |
| 146 | + print(warning, file=sys.stderr) |
| 147 | + print("false") |
| 148 | + return 0 |
68 | 149 |
|
69 | 150 |
|
70 | | -def main() -> int: |
71 | | - mode = sys.argv[1] if len(sys.argv) > 1 else "auto" |
| 151 | +def _parse_args(argv: list[str]) -> argparse.Namespace: |
| 152 | + """Parse the resolver's mode and the caller-supplied currency prefix.""" |
| 153 | + parser = argparse.ArgumentParser(description="Resolve CODEMAP_ENABLED to true/false.") |
| 154 | + parser.add_argument("mode", nargs="?", default="auto", help="auto | strict | off | true | false") |
| 155 | + parser.add_argument( |
| 156 | + "--currency-prefix", |
| 157 | + default=DEFAULT_CURRENCY_PREFIX, |
| 158 | + help="basename prefix of the currency sentinel; supplied by the calling plugin", |
| 159 | + ) |
| 160 | + return parser.parse_args(argv) |
| 161 | + |
| 162 | + |
| 163 | +def main(argv: list[str] | None = None) -> int: |
| 164 | + """Resolve the codemap mode, print true/false, and record index currency.""" |
| 165 | + args = _parse_args(list(sys.argv[1:] if argv is None else argv)) |
| 166 | + mode, prefix = args.mode, args.currency_prefix |
72 | 167 |
|
73 | 168 | if mode in ("off", "false"): |
74 | 169 | print("false") |
75 | | - _write_currency("off") |
| 170 | + _write_currency(prefix, "off") |
76 | 171 | return 0 |
77 | 172 | if mode == "true": |
78 | 173 | print("true") |
79 | 174 | return 0 |
80 | 175 |
|
81 | | - if not shutil.which("codemap-py"): |
82 | | - _write_currency("no_index") |
83 | | - if mode == "strict": |
84 | | - print( |
85 | | - f"! BREAKING — --codemap strict: {TOOL_LABEL} query not found. Install {TOOL_LABEL} plugin.", |
86 | | - file=sys.stderr, |
87 | | - ) |
88 | | - return 1 |
89 | | - print("false") |
90 | | - return 0 |
91 | | - |
92 | | - index = Path(os.environ.get("CODEMAP_INDEX_DIR", ".cache/codemap")) / f"{_project_name()}.json" |
| 176 | + if not shutil.which(TOOL_LABEL): |
| 177 | + return _unavailable(prefix, mode, f"{TOOL_LABEL} query not found. Install {TOOL_LABEL} plugin.", "") |
93 | 178 |
|
| 179 | + root = _canonical_root() |
| 180 | + index = _index_path(root) |
94 | 181 | if not index.is_file(): |
95 | | - _write_currency("no_index") |
96 | | - if mode == "strict": |
97 | | - print( |
98 | | - f"! BREAKING — --codemap strict: index {index} not found. Run /codemap-py:scan-codebase first.", |
99 | | - file=sys.stderr, |
100 | | - ) |
101 | | - return 1 |
102 | | - print( |
103 | | - f"⚠ {TOOL_LABEL}: no index for project '{_project_name()}' at {index}\n" |
| 182 | + return _unavailable( |
| 183 | + prefix, |
| 184 | + mode, |
| 185 | + f"index {index} not found. Run /codemap-py:scan-codebase first.", |
| 186 | + f"⚠ {TOOL_LABEL}: no index for project '{root.name}' at {index}\n" |
104 | 187 | " Run /codemap-py:scan-codebase to build it, then re-run this skill.", |
105 | | - file=sys.stderr, |
106 | 188 | ) |
107 | | - print("false") |
108 | | - return 0 |
109 | 189 |
|
110 | | - cic = shutil.which("check-index-currency") |
111 | | - if cic: |
112 | | - currency = _currency_field(cic, index, "status", "current") |
113 | | - reason = _currency_field(cic, index, "reason", "") |
114 | | - _write_currency(currency) |
115 | | - if currency == "stale": |
116 | | - print( |
117 | | - f"⚠ {TOOL_LABEL}: index is stale — {reason}\n Run /codemap-py:scan-codebase to refresh it.", |
118 | | - file=sys.stderr, |
119 | | - ) |
120 | | - else: |
121 | | - _write_currency("current") |
| 190 | + _record_currency(prefix, index) |
122 | 191 | print("true") |
123 | 192 | return 0 |
124 | 193 |
|
|
0 commit comments