codecortex 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of codecortex might be problematic. Click here for more details.

codeintel/indexer.py ADDED
@@ -0,0 +1,250 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import logging
5
+ import os
6
+ import struct
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING
9
+
10
+ if TYPE_CHECKING:
11
+ from codeintel.semantic_db import SemanticDb
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ _INDEXED_EXTS = frozenset({
16
+ ".py", ".ts", ".js", ".go", ".rs", ".java", ".c", ".cpp", ".h", ".md"
17
+ })
18
+ _SKIP_DIRS = frozenset({"__pycache__", ".git", "node_modules"})
19
+ # Vendored / regenerable dirs skipped even without a .gitignore entry.
20
+ _DEFAULT_IGNORES = frozenset({
21
+ ".venv", "venv", "env", "dist", "build", "target",
22
+ ".mypy_cache", ".pytest_cache", ".tox", ".idea", ".vscode", ".cache",
23
+ })
24
+
25
+
26
+ def _project_key(project_root_real: str) -> str:
27
+ """A short, stable id for a project root — prefixes every chunk_id so two repos
28
+ with an identically-named file never collide in the shared cache."""
29
+ return hashlib.sha256(project_root_real.encode()).hexdigest()[:12]
30
+
31
+
32
+ class Indexer:
33
+ def __init__(
34
+ self,
35
+ db: SemanticDb,
36
+ model_name: str = "BAAI/bge-small-en-v1.5",
37
+ window: int = 20,
38
+ stride: int = 10,
39
+ max_chunks: int = 500,
40
+ ) -> None:
41
+ self.db = db
42
+ self.model_name = model_name
43
+ self.window = window
44
+ self.stride = stride
45
+ self.max_chunks = max_chunks
46
+ self._embedder = None
47
+
48
+ def _get_embedder(self):
49
+ if self._embedder is None:
50
+ from fastembed import TextEmbedding
51
+ self._embedder = TextEmbedding(model_name=self.model_name)
52
+ return self._embedder
53
+
54
+ def index(self, project_root: str) -> int:
55
+ """Return count of newly embedded chunks, or -1 on unrecoverable failure."""
56
+ try:
57
+ return self._index(project_root)
58
+ except Exception as exc:
59
+ logger.error("Indexer.index() unrecoverable failure: %s", exc)
60
+ return -1
61
+
62
+ def _load_gitignore(self, root: Path) -> set[str]:
63
+ """Best-effort ``.gitignore``: collect simple name/dir patterns to skip. This is
64
+ NOT full gitignore semantics (no globs, negations, or nesting) — just enough to
65
+ avoid indexing vendored/build output the user already told git to ignore."""
66
+ patterns: set[str] = set()
67
+ gi = root / ".gitignore"
68
+ try:
69
+ if gi.is_file():
70
+ for line in gi.read_text(encoding="utf-8", errors="replace").splitlines():
71
+ line = line.strip()
72
+ if not line or line.startswith("#") or line.startswith("!"):
73
+ continue
74
+ name = line.rstrip("/").lstrip("/")
75
+ if name and "*" not in name and "/" not in name:
76
+ patterns.add(name)
77
+ except Exception:
78
+ pass
79
+ return patterns
80
+
81
+ def _cleanup_deleted(self, root: Path, project_root_real: str) -> None:
82
+ """Drop rows for THIS project whose file no longer exists — scoped by
83
+ project_root so touching one repo can never purge another's index."""
84
+ conn = self.db.conn()
85
+ try:
86
+ rows = conn.execute(
87
+ "SELECT DISTINCT file_path FROM chunk_hashes WHERE project_root = ?",
88
+ (project_root_real,),
89
+ ).fetchall()
90
+ deleted_paths = [
91
+ row[0] for row in rows if not (root / row[0]).exists()
92
+ ]
93
+ for fp in deleted_paths:
94
+ chunk_ids = [
95
+ r[0]
96
+ for r in conn.execute(
97
+ "SELECT chunk_id FROM chunk_hashes"
98
+ " WHERE project_root = ? AND file_path = ?",
99
+ (project_root_real, fp),
100
+ ).fetchall()
101
+ ]
102
+ for cid in chunk_ids:
103
+ conn.execute(
104
+ "DELETE FROM code_embeddings WHERE chunk_id = ?", (cid,)
105
+ )
106
+ conn.execute(
107
+ "DELETE FROM chunk_hashes WHERE chunk_id = ?", (cid,)
108
+ )
109
+ conn.commit()
110
+ except Exception as exc:
111
+ logger.warning("Cleanup pass failed: %s", exc)
112
+
113
+ def _walk_files(self, root: Path):
114
+ ignores = set(_SKIP_DIRS) | set(_DEFAULT_IGNORES) | self._load_gitignore(root)
115
+ for dirpath, dirnames, filenames in os.walk(root):
116
+ dirnames[:] = [
117
+ d for d in dirnames
118
+ if d not in ignores and not d.endswith(".egg-info")
119
+ ]
120
+ for fname in filenames:
121
+ if fname in ignores:
122
+ continue
123
+ if Path(fname).suffix.lower() in _INDEXED_EXTS:
124
+ yield Path(dirpath) / fname
125
+
126
+ def _collect_new_chunks(
127
+ self, root: Path, project_key: str, project_root_real: str
128
+ ) -> list[tuple[str, str, str, int, str]]:
129
+ """Walk files; return (chunk_id, text, rel_path, start, hash) for new/changed chunks."""
130
+ conn = self.db.conn()
131
+ new_chunks: list[tuple[str, str, str, int, str]] = []
132
+
133
+ for filepath in self._walk_files(root):
134
+ try:
135
+ with open(filepath, encoding="utf-8", errors="replace") as f:
136
+ lines = f.readlines()
137
+ except FileNotFoundError:
138
+ logger.debug("file disappeared: %s", filepath)
139
+ continue
140
+ except Exception as exc:
141
+ logger.debug("skipping %s: %s", filepath, exc)
142
+ continue
143
+
144
+ rel_path = str(filepath.relative_to(root))
145
+ chunk_count = 0
146
+
147
+ for chunk_start in range(0, len(lines), self.stride):
148
+ if chunk_count >= self.max_chunks:
149
+ logger.debug(
150
+ "chunk cap hit for %s, truncating at %d",
151
+ rel_path,
152
+ self.max_chunks,
153
+ )
154
+ break
155
+
156
+ chunk_lines = lines[chunk_start: chunk_start + self.window]
157
+ if not chunk_lines:
158
+ break
159
+
160
+ chunk_text = "".join(chunk_lines)
161
+ if not chunk_text.strip():
162
+ # EC3.4: never embed empty/whitespace-only chunks (zero vectors pollute results).
163
+ chunk_count += 1
164
+ continue
165
+
166
+ chunk_id = f"{project_key}:{rel_path}:{chunk_start}"
167
+ content_hash = hashlib.sha256(chunk_text.encode()).hexdigest()[:16]
168
+
169
+ try:
170
+ row = conn.execute(
171
+ "SELECT content_hash FROM chunk_hashes WHERE chunk_id = ?",
172
+ (chunk_id,),
173
+ ).fetchone()
174
+ if row and row[0] == content_hash:
175
+ chunk_count += 1
176
+ continue
177
+ except Exception as exc:
178
+ logger.debug("hash check failed for %s: %s", chunk_id, exc)
179
+
180
+ new_chunks.append(
181
+ (chunk_id, chunk_text, rel_path, chunk_start, content_hash)
182
+ )
183
+ chunk_count += 1
184
+
185
+ return new_chunks
186
+
187
+ def _embed_and_write(
188
+ self, new_chunks: list[tuple[str, str, str, int, str]], project_root_real: str
189
+ ) -> int:
190
+ embedder = self._get_embedder() # may raise → propagates to index() → returns -1
191
+ conn = self.db.conn()
192
+ embedded_count = 0
193
+ batch_size = 32
194
+
195
+ for i in range(0, len(new_chunks), batch_size):
196
+ batch = new_chunks[i: i + batch_size]
197
+ texts = [c[1] for c in batch]
198
+
199
+ try:
200
+ embeddings = list(embedder.embed(texts))
201
+ except Exception as exc:
202
+ logger.warning("embedding batch %d failed: %s", i // batch_size, exc)
203
+ continue
204
+
205
+ for j, (chunk_id, _, rel_path, chunk_start, content_hash) in enumerate(batch):
206
+ if j >= len(embeddings):
207
+ break
208
+ try:
209
+ vec = embeddings[j]
210
+ vec_bytes = struct.pack(f"{len(vec)}f", *vec)
211
+ conn.execute(
212
+ "INSERT OR REPLACE INTO code_embeddings(chunk_id, embedding)"
213
+ " VALUES (?, ?)",
214
+ (chunk_id, vec_bytes),
215
+ )
216
+ conn.execute(
217
+ "INSERT OR REPLACE INTO chunk_hashes"
218
+ "(chunk_id, project_root, file_path, chunk_start, content_hash)"
219
+ " VALUES (?, ?, ?, ?, ?)",
220
+ (chunk_id, project_root_real, rel_path, chunk_start, content_hash),
221
+ )
222
+ embedded_count += 1
223
+ except Exception as exc:
224
+ logger.warning("writing chunk %s failed: %s", chunk_id, exc)
225
+
226
+ try:
227
+ conn.commit()
228
+ except Exception as exc:
229
+ logger.warning("commit failed after batch %d: %s", i // batch_size, exc)
230
+
231
+ return embedded_count
232
+
233
+ def _index(self, project_root: str) -> int:
234
+ if not project_root:
235
+ return 0
236
+
237
+ root = Path(project_root)
238
+ if not root.exists():
239
+ return 0
240
+
241
+ project_root_real = os.path.realpath(project_root)
242
+ project_key = _project_key(project_root_real)
243
+
244
+ self._cleanup_deleted(root, project_root_real)
245
+
246
+ new_chunks = self._collect_new_chunks(root, project_key, project_root_real)
247
+ if not new_chunks:
248
+ return 0
249
+
250
+ return self._embed_and_write(new_chunks, project_root_real)
codeintel/injector.py ADDED
@@ -0,0 +1,81 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import os
5
+
6
+ _START_MARKER = "<!-- codeintel-map-start -->"
7
+ _END_MARKER = "<!-- codeintel-map-end -->"
8
+ _CONTEXT_FILES = ["CLAUDE.md", "AGENTS.md"]
9
+
10
+ _BLOCK_CONTENT = (
11
+ "\n## codeintel orientation map\n\n"
12
+ "See [CODE_INTEL.md](CODE_INTEL.md) for a ranked overview of this codebase "
13
+ "modules, key symbols (by call frequency), and entry points. "
14
+ "Refresh with: `codeintel map`."
15
+ "\n"
16
+ )
17
+
18
+ _logger = logging.getLogger(__name__)
19
+
20
+
21
+ class Injector:
22
+ """Idempotently injects a CODE_INTEL.md reference block into CLAUDE.md or AGENTS.md."""
23
+
24
+ def inject(self, project_root: str) -> tuple[str | None, str]:
25
+ try:
26
+ path = _find_context_file(project_root)
27
+ if path is None:
28
+ return (None, "no-context-file")
29
+
30
+ content = _read_file(path)
31
+ if content is None:
32
+ return (None, "error")
33
+
34
+ new_content, action = _update_block(content)
35
+ _write_file(path, new_content)
36
+ return (path, action)
37
+ except Exception as exc:
38
+ _logger.warning("Injector.inject failed: %s", exc)
39
+ return (None, "error")
40
+
41
+
42
+ def _find_context_file(project_root: str) -> str | None:
43
+ for name in _CONTEXT_FILES:
44
+ candidate = os.path.join(project_root, name)
45
+ if os.path.isfile(candidate):
46
+ return candidate
47
+ return None
48
+
49
+
50
+ def _read_file(path: str) -> str | None:
51
+ try:
52
+ with open(path, encoding="utf-8", errors="strict") as f:
53
+ return f.read()
54
+ except Exception as exc:
55
+ _logger.warning("Injector: could not read %s: %s", path, exc)
56
+ return None
57
+
58
+
59
+ def _write_file(path: str, content: str) -> None:
60
+ with open(path, "w", encoding="utf-8") as f:
61
+ f.write(content)
62
+
63
+
64
+ def _update_block(content: str) -> tuple[str, str]:
65
+ has_start = _START_MARKER in content
66
+ has_end = _END_MARKER in content
67
+
68
+ block = _START_MARKER + _BLOCK_CONTENT + _END_MARKER
69
+
70
+ if has_start and has_end:
71
+ start_idx = content.index(_START_MARKER)
72
+ end_idx = content.index(_END_MARKER) + len(_END_MARKER)
73
+ new_content = content[:start_idx] + block + content[end_idx:]
74
+ return (new_content, "updated")
75
+
76
+ # Missing or corrupted (only one marker): append a fresh block
77
+ separator = "\n\n" if not content.endswith("\n\n") else ""
78
+ if content.endswith("\n"):
79
+ separator = "\n"
80
+ new_content = content + separator + block
81
+ return (new_content, "appended")
codeintel/installer.py ADDED
@@ -0,0 +1,103 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import pathlib
5
+
6
+ _AGENTS = ["claude", "codex", "gemini", "zed"]
7
+
8
+ _CONFIG: dict[str, dict] = {
9
+ "claude": {
10
+ "path": "~/.claude/settings.json",
11
+ "key": ["mcpServers", "codeintel"],
12
+ "value": {"command": "codeintel", "args": ["serve"]},
13
+ },
14
+ "codex": {
15
+ "path": "~/.codex/config.json",
16
+ "key": ["mcpServers", "codeintel"],
17
+ "value": {"command": "codeintel", "args": ["serve"]},
18
+ },
19
+ "gemini": {
20
+ "path": "~/.gemini/settings.json",
21
+ "key": ["mcpServers", "codeintel"],
22
+ "value": {"command": "codeintel", "args": ["serve"]},
23
+ },
24
+ "zed": {
25
+ "path": "~/.config/zed/settings.json",
26
+ "key": ["context_servers", "codeintel"],
27
+ "value": {"command": {"path": "codeintel", "args": ["serve"]}},
28
+ },
29
+ }
30
+
31
+
32
+ def _get_nested(data: dict, keys: list[str]):
33
+ node = data
34
+ for k in keys:
35
+ if not isinstance(node, dict) or k not in node:
36
+ return None
37
+ node = node[k]
38
+ return node
39
+
40
+
41
+ def _set_nested(data: dict, keys: list[str], value) -> None:
42
+ node = data
43
+ for k in keys[:-1]:
44
+ if k not in node or not isinstance(node[k], dict):
45
+ node[k] = {}
46
+ node = node[k]
47
+ node[keys[-1]] = value
48
+
49
+
50
+ class Installer:
51
+ def register(self, agent: str) -> dict:
52
+ spec = _CONFIG.get(agent)
53
+ if spec is None:
54
+ return {
55
+ "agent": agent,
56
+ "path": "",
57
+ "ok": False,
58
+ "action": "failed",
59
+ "reason": f"unknown agent '{agent}'",
60
+ }
61
+
62
+ config_path = pathlib.Path(spec["path"]).expanduser()
63
+ try:
64
+ if config_path.exists():
65
+ data = json.loads(config_path.read_text(encoding="utf-8"))
66
+ if not isinstance(data, dict):
67
+ data = {}
68
+ else:
69
+ data = {}
70
+
71
+ current = _get_nested(data, spec["key"])
72
+ if current == spec["value"]:
73
+ return {
74
+ "agent": agent,
75
+ "path": str(config_path),
76
+ "ok": True,
77
+ "action": "already",
78
+ "reason": "",
79
+ }
80
+
81
+ config_path.parent.mkdir(parents=True, exist_ok=True)
82
+ _set_nested(data, spec["key"], spec["value"])
83
+ config_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
84
+
85
+ return {
86
+ "agent": agent,
87
+ "path": str(config_path),
88
+ "ok": True,
89
+ "action": "registered",
90
+ "reason": "",
91
+ }
92
+
93
+ except Exception as exc:
94
+ return {
95
+ "agent": agent,
96
+ "path": str(config_path),
97
+ "ok": False,
98
+ "action": "failed",
99
+ "reason": str(exc),
100
+ }
101
+
102
+ def register_all(self) -> list[dict]:
103
+ return [self.register(agent) for agent in _AGENTS]
codeintel/mapper.py ADDED
@@ -0,0 +1,192 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from typing import Optional, TYPE_CHECKING
5
+
6
+ if TYPE_CHECKING:
7
+ from codeintel.providers.graph import GraphProvider
8
+
9
+ # Real codebase-memory-mcp contract (verified live): query_graph returns {columns, rows}
10
+ # value-arrays, module-level calls are USAGE (not only CALLS), and nodes carry `is_entry_point`
11
+ # (there is no `fn.type` property — that filter matched nothing, so the old queries were dead).
12
+ _FAN_IN_CYPHER = (
13
+ "MATCH (caller)-[:CALLS|USAGE]->(fn) WHERE fn.name IS NOT NULL "
14
+ "RETURN fn.name, fn.qualified_name, fn.file_path, count(caller) AS in_degree "
15
+ "ORDER BY in_degree DESC LIMIT 40"
16
+ )
17
+
18
+ _ENTRY_CYPHER = (
19
+ "MATCH (fn) WHERE fn.is_entry_point = true "
20
+ "RETURN fn.name, fn.file_path LIMIT 10"
21
+ )
22
+
23
+ # Builtins and the synthetic project/config node are high-fan-in but useless as "symbols".
24
+ _RANK_SKIP_PATHS = frozenset({"<python-builtins>", "pyproject.toml", ""})
25
+
26
+
27
+ def _rows_from(raw: object) -> list[dict]:
28
+ """Normalize a query_graph response to a list of column->value dicts.
29
+
30
+ Accepts the real ``{"columns": [...], "rows": [[...]]}`` shape AND the legacy list-of-dicts
31
+ shape (used by older mocks); anything else yields ``[]``. Mirrors GraphProvider._query_rows
32
+ so the map generator reads the same reality every other graph op now does."""
33
+ if isinstance(raw, list):
34
+ return [r for r in raw if isinstance(r, dict)]
35
+ if not isinstance(raw, dict):
36
+ return []
37
+ cols, rows = raw.get("columns"), raw.get("rows")
38
+ if not isinstance(cols, list) or not isinstance(rows, list):
39
+ return []
40
+ out: list[dict] = []
41
+ for row in rows:
42
+ if isinstance(row, list):
43
+ out.append({str(cols[i]): row[i] for i in range(min(len(cols), len(row)))})
44
+ elif isinstance(row, dict):
45
+ out.append(row)
46
+ return out
47
+
48
+
49
+ class MapGenerator:
50
+ """Generates a ranked, byte-bounded CODE_INTEL.md from the graph index."""
51
+
52
+ def __init__(self, provider: Optional[GraphProvider] = None) -> None:
53
+ self._provider = provider
54
+
55
+ def generate(self, project_root: str, budget_bytes: int = 32768) -> str:
56
+ provider = self._provider
57
+ if not provider or not getattr(provider, "available", False):
58
+ return _minimal_map(project_root, note="graph engine not available — install codebase-memory-mcp and run `codeintel index`")
59
+
60
+ # Top-level guard: the map is best-effort orientation — a malformed backend
61
+ # response must degrade to a minimal map, never crash the caller (never-raise).
62
+ try:
63
+ project = provider._resolve_project(project_root)
64
+ if project is None:
65
+ return _minimal_map(project_root, note="project not yet indexed — run `codeintel index` first")
66
+
67
+ # Query 1: architecture overview
68
+ arch_result = provider.build_result("overview", "", [], 5000, project_root)
69
+ arch_text: Optional[str] = None
70
+ if arch_result and arch_result.get("ok") and arch_result.get("result"):
71
+ arch_text = str(arch_result["result"])
72
+
73
+ # Query 2: ranked symbols by fan-in
74
+ ranked_rows = _query_ranked_symbols(provider, project)
75
+
76
+ # Query 3: entry points
77
+ entry_rows = _query_entry_points(provider, project)
78
+
79
+ content = _render(project_root, arch_text, ranked_rows, entry_rows)
80
+ content = _enforce_budget(content, budget_bytes, project_root, arch_text, ranked_rows, entry_rows)
81
+ return content
82
+ except Exception as exc:
83
+ return _minimal_map(project_root, note=f"map generation failed: {exc}")
84
+
85
+ def write(self, project_root: str, content: str) -> str:
86
+ path = os.path.join(project_root, "CODE_INTEL.md")
87
+ try:
88
+ with open(path, "w", encoding="utf-8") as f:
89
+ f.write(content)
90
+ except Exception as exc:
91
+ import logging
92
+ logging.getLogger(__name__).warning("CODE_INTEL.md write failed: %s", exc)
93
+ return path
94
+
95
+
96
+ def _query_ranked_symbols(provider: GraphProvider, project: str) -> list[dict]:
97
+ raw = provider._run("query_graph", {"project": project, "query": _FAN_IN_CYPHER}, 8000)
98
+ rows = []
99
+ for row in _rows_from(raw):
100
+ name = row.get("fn.name") or row.get("name") or "?"
101
+ path = str(row.get("fn.file_path") or row.get("file_path") or "")
102
+ deg = row.get("in_degree", 0)
103
+ if path in _RANK_SKIP_PATHS: # drop builtins / project node noise
104
+ continue
105
+ rows.append({"name": name, "file_path": path, "in_degree": deg})
106
+ if len(rows) >= 30:
107
+ break
108
+ return rows
109
+
110
+
111
+ def _query_entry_points(provider: GraphProvider, project: str) -> list[dict]:
112
+ raw = provider._run("query_graph", {"project": project, "query": _ENTRY_CYPHER}, 5000)
113
+ rows = []
114
+ for row in _rows_from(raw):
115
+ name = row.get("fn.name") or row.get("name") or "?"
116
+ path = str(row.get("fn.file_path") or row.get("file_path") or "")
117
+ rows.append({"name": name, "file_path": path})
118
+ return rows
119
+
120
+
121
+ def _render(
122
+ project_root: str,
123
+ arch_text: Optional[str],
124
+ ranked_rows: list[dict],
125
+ entry_rows: list[dict],
126
+ ) -> str:
127
+ repo_name = os.path.basename(os.path.abspath(project_root)) if project_root else "repo"
128
+ parts: list[str] = [f"# CODE_INTEL.md — {repo_name}\n"]
129
+ parts.append(
130
+ "> Auto-generated by `codeintel map`. Re-run after `codeintel index` to refresh.\n"
131
+ )
132
+
133
+ if arch_text:
134
+ parts.append("## Architecture Overview\n")
135
+ parts.append(arch_text.strip())
136
+ parts.append("")
137
+
138
+ if ranked_rows:
139
+ parts.append("## Ranked Symbols (by caller count)\n")
140
+ parts.append("| Symbol | File | Callers |")
141
+ parts.append("|--------|------|---------|")
142
+ for row in ranked_rows:
143
+ parts.append(f"| `{row['name']}` | {row['file_path']} | {row['in_degree']} |")
144
+ parts.append("")
145
+ else:
146
+ parts.append("## Ranked Symbols\n")
147
+ parts.append("_(no symbols found — run `codeintel index` first)_\n")
148
+
149
+ if entry_rows:
150
+ parts.append("## Entry Points\n")
151
+ for row in entry_rows:
152
+ parts.append(f"- `{row['name']}` ({row['file_path']})")
153
+ parts.append("")
154
+
155
+ return "\n".join(parts)
156
+
157
+
158
+ def _enforce_budget(
159
+ content: str,
160
+ budget_bytes: int,
161
+ project_root: str,
162
+ arch_text: Optional[str],
163
+ ranked_rows: list[dict],
164
+ entry_rows: list[dict],
165
+ ) -> str:
166
+ if budget_bytes <= 0:
167
+ budget_bytes = 32768
168
+
169
+ if len(content.encode("utf-8")) <= budget_bytes:
170
+ return content
171
+
172
+ # Binary-search-style: drop rows from ranked table until we fit, then append notice
173
+ rows = list(ranked_rows)
174
+ notice = f"\n> Content truncated to fit {budget_bytes} byte budget.\n"
175
+ while rows:
176
+ candidate = _render(project_root, arch_text, rows, entry_rows) + notice
177
+ if len(candidate.encode("utf-8")) <= budget_bytes:
178
+ return candidate
179
+ rows = rows[:-1]
180
+
181
+ # Even with no ranked rows the header+notice must still be returned
182
+ candidate = _render(project_root, arch_text, [], entry_rows) + notice
183
+ return candidate
184
+
185
+
186
+ def _minimal_map(project_root: str, note: str) -> str:
187
+ repo_name = os.path.basename(os.path.abspath(project_root)) if project_root else "repo"
188
+ return (
189
+ f"# CODE_INTEL.md — {repo_name}\n\n"
190
+ f"> Auto-generated by `codeintel map`.\n\n"
191
+ f"**Note**: {note}\n"
192
+ )