vigil-codeintel 0.1.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.
Files changed (131) hide show
  1. vigil_codeintel-0.1.0.dist-info/METADATA +780 -0
  2. vigil_codeintel-0.1.0.dist-info/RECORD +131 -0
  3. vigil_codeintel-0.1.0.dist-info/WHEEL +5 -0
  4. vigil_codeintel-0.1.0.dist-info/entry_points.txt +3 -0
  5. vigil_codeintel-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. vigil_codeintel-0.1.0.dist-info/top_level.txt +3 -0
  7. vigil_forensic/__init__.py +224 -0
  8. vigil_forensic/_git_utils.py +178 -0
  9. vigil_forensic/_shared.py +510 -0
  10. vigil_forensic/_stubs.py +156 -0
  11. vigil_forensic/gate_checks/__init__.py +1 -0
  12. vigil_forensic/gate_checks/_ast_helpers.py +629 -0
  13. vigil_forensic/gate_checks/_deployment_detector.py +573 -0
  14. vigil_forensic/gate_checks/atomic_write_checks.py +1143 -0
  15. vigil_forensic/gate_checks/authority_checks.py +95 -0
  16. vigil_forensic/gate_checks/boundary_breach_checks.py +202 -0
  17. vigil_forensic/gate_checks/broad_except_checks.py +301 -0
  18. vigil_forensic/gate_checks/broad_except_hidden_sentinel_checks.py +365 -0
  19. vigil_forensic/gate_checks/common.py +253 -0
  20. vigil_forensic/gate_checks/config_safety_checks.py +704 -0
  21. vigil_forensic/gate_checks/config_ssot_checks.py +78 -0
  22. vigil_forensic/gate_checks/conflict_checks.py +193 -0
  23. vigil_forensic/gate_checks/context_fallback_checks.py +697 -0
  24. vigil_forensic/gate_checks/context_health_checks.py +289 -0
  25. vigil_forensic/gate_checks/contract_shape_drift_checks.py +459 -0
  26. vigil_forensic/gate_checks/dirty_baseline_check.py +274 -0
  27. vigil_forensic/gate_checks/duplication_checks.py +387 -0
  28. vigil_forensic/gate_checks/embedded_string_checks.py +123 -0
  29. vigil_forensic/gate_checks/empty_output_checks.py +87 -0
  30. vigil_forensic/gate_checks/encoding_checks.py +847 -0
  31. vigil_forensic/gate_checks/export_completeness_checks.py +156 -0
  32. vigil_forensic/gate_checks/fallback_checks.py +41 -0
  33. vigil_forensic/gate_checks/file_proliferation_checks.py +171 -0
  34. vigil_forensic/gate_checks/fix_without_test_checks.py +69 -0
  35. vigil_forensic/gate_checks/forensic_cluster_runners/__init__.py +9 -0
  36. vigil_forensic/gate_checks/forensic_cluster_runners/_helpers.py +71 -0
  37. vigil_forensic/gate_checks/forensic_cluster_runners/advanced_checks.py +322 -0
  38. vigil_forensic/gate_checks/forensic_cluster_runners/core.py +273 -0
  39. vigil_forensic/gate_checks/forensic_cluster_runners/integrity_checks.py +203 -0
  40. vigil_forensic/gate_checks/forensic_cluster_runners/quality_checks.py +666 -0
  41. vigil_forensic/gate_checks/forensic_clusters/__init__.py +193 -0
  42. vigil_forensic/gate_checks/forensic_clusters/allowlist.py +426 -0
  43. vigil_forensic/gate_checks/forensic_clusters/allowlist_writer.py +302 -0
  44. vigil_forensic/gate_checks/forensic_clusters/api_protocol.py +231 -0
  45. vigil_forensic/gate_checks/forensic_clusters/async_quality.py +1156 -0
  46. vigil_forensic/gate_checks/forensic_clusters/code_style.py +808 -0
  47. vigil_forensic/gate_checks/forensic_clusters/core.py +319 -0
  48. vigil_forensic/gate_checks/forensic_clusters/data_quality.py +763 -0
  49. vigil_forensic/gate_checks/forensic_clusters/dead_code.py +480 -0
  50. vigil_forensic/gate_checks/forensic_clusters/edit_mutation.py +842 -0
  51. vigil_forensic/gate_checks/forensic_clusters/exception_boundary.py +240 -0
  52. vigil_forensic/gate_checks/forensic_clusters/legacy_debt.py +556 -0
  53. vigil_forensic/gate_checks/forensic_clusters/static_analysis.py +834 -0
  54. vigil_forensic/gate_checks/forensic_clusters/structural_quality.py +298 -0
  55. vigil_forensic/gate_checks/god_object_zones_checks.py +173 -0
  56. vigil_forensic/gate_checks/hallucination_checks.py +566 -0
  57. vigil_forensic/gate_checks/hunter_artifact_completeness_check.py +139 -0
  58. vigil_forensic/gate_checks/implementation_overfit_checks.py +380 -0
  59. vigil_forensic/gate_checks/import_integrity_checks.py +233 -0
  60. vigil_forensic/gate_checks/imports_in_function_checks.py +283 -0
  61. vigil_forensic/gate_checks/ml_checks.py +318 -0
  62. vigil_forensic/gate_checks/performance_checks.py +106 -0
  63. vigil_forensic/gate_checks/project_specific_runner.py +691 -0
  64. vigil_forensic/gate_checks/provider_capability_checks.py +73 -0
  65. vigil_forensic/gate_checks/refactor_completeness_checks.py +274 -0
  66. vigil_forensic/gate_checks/reliability_checks.py +389 -0
  67. vigil_forensic/gate_checks/reporting_checks.py +55 -0
  68. vigil_forensic/gate_checks/runtime_behavior_checks.py +220 -0
  69. vigil_forensic/gate_checks/security_injection_checks.py +332 -0
  70. vigil_forensic/gate_checks/semantic_intent_checks.py +139 -0
  71. vigil_forensic/gate_checks/size_complexity_checks.py +336 -0
  72. vigil_forensic/gate_checks/stuck_feature_flag_checks.py +354 -0
  73. vigil_forensic/gate_checks/syntax_validity_checks.py +217 -0
  74. vigil_forensic/gate_checks/temporal_freshness_checks.py +79 -0
  75. vigil_forensic/gate_checks/test_quality_checks.py +946 -0
  76. vigil_forensic/gate_checks/testing_checks.py +149 -0
  77. vigil_forensic/gate_checks/toctou_checks.py +367 -0
  78. vigil_forensic/gate_checks/type_checking_checks.py +316 -0
  79. vigil_forensic/gate_models.py +392 -0
  80. vigil_forensic/gate_packs/__init__.py +1 -0
  81. vigil_forensic/gate_packs/universal.py +179 -0
  82. vigil_forensic/gate_profile.json +31 -0
  83. vigil_forensic/gate_registry.py +21 -0
  84. vigil_forensic/language_profiles.py +219 -0
  85. vigil_forensic/meta_findings.py +207 -0
  86. vigil_forensic/self_audit.py +725 -0
  87. vigil_forensic/source_analysis.py +175 -0
  88. vigil_mapper/__init__.py +103 -0
  89. vigil_mapper/_ast_helpers_minimal.py +229 -0
  90. vigil_mapper/_extract_imports_impl.py +123 -0
  91. vigil_mapper/_file_count_guard.py +129 -0
  92. vigil_mapper/_git_utils.py +178 -0
  93. vigil_mapper/_runtime_ast.py +438 -0
  94. vigil_mapper/_runtime_dispatch.py +137 -0
  95. vigil_mapper/_seed_helpers.py +82 -0
  96. vigil_mapper/authority_builder.py +1102 -0
  97. vigil_mapper/cli_entry.py +731 -0
  98. vigil_mapper/conflict_builder.py +818 -0
  99. vigil_mapper/data_contract_builder.py +446 -0
  100. vigil_mapper/findings_builder.py +716 -0
  101. vigil_mapper/fingerprint.py +53 -0
  102. vigil_mapper/hotspot_builder.py +539 -0
  103. vigil_mapper/map_common.py +449 -0
  104. vigil_mapper/map_errors.py +55 -0
  105. vigil_mapper/map_models.py +431 -0
  106. vigil_mapper/map_models_ext.py +206 -0
  107. vigil_mapper/map_models_findings.py +130 -0
  108. vigil_mapper/map_storage.py +455 -0
  109. vigil_mapper/parse_cache.py +795 -0
  110. vigil_mapper/refactor_boundary_builder.py +266 -0
  111. vigil_mapper/runtime_builder.py +527 -0
  112. vigil_mapper/runtime_tracer.py +243 -0
  113. vigil_mapper/runtime_tracer_entry.py +199 -0
  114. vigil_mapper/semantic_diff.py +71 -0
  115. vigil_mapper/source_adapters/__init__.py +109 -0
  116. vigil_mapper/source_adapters/_base.py +264 -0
  117. vigil_mapper/source_adapters/_ir.py +156 -0
  118. vigil_mapper/source_adapters/_lexer.py +309 -0
  119. vigil_mapper/source_adapters/_patterns.py +212 -0
  120. vigil_mapper/source_adapters/_treesitter.py +182 -0
  121. vigil_mapper/source_adapters/go.py +553 -0
  122. vigil_mapper/source_adapters/java.py +541 -0
  123. vigil_mapper/source_adapters/javascript.py +626 -0
  124. vigil_mapper/source_adapters/python.py +325 -0
  125. vigil_mapper/source_adapters/typescript.py +749 -0
  126. vigil_mapper/structural_builder.py +586 -0
  127. vigil_mcp/__init__.py +1 -0
  128. vigil_mcp/_jobs.py +587 -0
  129. vigil_mcp/_paths.py +93 -0
  130. vigil_mcp/forensic_server.py +419 -0
  131. vigil_mcp/map_server.py +452 -0
@@ -0,0 +1,452 @@
1
+ """FastMCP stdio server: code-map
2
+
3
+ Wraps vigil_mapper.run_map_build + load_repo_maps behind a
4
+ background-job poll API. Resource constraints:
5
+ - At most 2 concurrent jobs (enforced by _jobs.JobRegistry).
6
+ - One thread per job (no pool).
7
+ - Output truncated/paginated to OUTPUT_CHAR_LIMIT chars to stay within
8
+ MCP token limits (~25 k tokens ≈ ~100 k chars; we use 80 k to be safe).
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from mcp.server.fastmcp import FastMCP
17
+
18
+ from vigil_mcp import _jobs
19
+ from vigil_mcp import _paths
20
+ from vigil_mapper import run_map_build, load_repo_maps
21
+
22
+ _INSTRUCTIONS = """\
23
+ code-map - builds structural maps of a codebase across Python/Go/Java/JS/TS:
24
+ imports/dependencies, defined symbols, runtime entry points, data contracts,
25
+ authority/write sites, risk hotspots, and refactor boundaries. Static analysis
26
+ (tree-sitter/AST) - it never runs the project.
27
+
28
+ WHEN TO USE: when the user wants to understand an unfamiliar codebase's architecture,
29
+ find what imports/depends on what, locate runtime entry points or risk hotspots, or
30
+ scope a refactor. Not a code-quality auditor (use forensic-audit for bugs/smells).
31
+
32
+ WORKFLOW (background job + poll):
33
+ 1. start_code_map(path="", map="all") -> leave path empty to auto-detect the project
34
+ root from the current directory; returns {job_id, resolved_path}.
35
+ 2. get_code_map_status(job_id) -> poll until status == "done".
36
+ 3. get_code_map_results(job_id) -> COMPACT SUMMARY by default (per-map-type
37
+ counts + top entries). Read this FIRST; it fits the context budget.
38
+ 4. Drill in: get_code_map_results(job_id, map="structural") for one full map type,
39
+ or view="full" for every map (both paginated via page=).
40
+ Also: load_code_map_by_path(path) re-reads maps built in an earlier session (no job).
41
+
42
+ SEEDS (optional refinement): 6 maps need no config. Three can be refined by a JSON
43
+ seed under <project>/.cortex/map_seeds/:
44
+ - authority_domains.json: group write sites into named domains (without it, each
45
+ writer is auto-surfaced on its own).
46
+ - runtime_seed.json: declare extra runtime nodes beyond auto-discovered entrypoints.
47
+ - refactor_boundaries.json: define the refactor goal/boundaries (refactor_boundary
48
+ is seed-driven and needs a goal).
49
+ See docs/usage/code-map.* (section "Seeds") for the JSON format and templates.
50
+
51
+ HUGE REPOS (anti-hang): if the collected file count exceeds max_files (default 800),
52
+ the build is SKIPPED and get_code_map_results returns skipped_reason="too_many_files"
53
+ with top_subdirs + a suggestion - build a submodule (start_code_map(path='<dir>/<subdir>'))
54
+ or pass a larger max_files to force a full build.
55
+
56
+ NOTE: output is summary-first to stay within the context budget; maps are cached on
57
+ disk under <project>/.cortex/ so re-runs are cheap.
58
+ """
59
+
60
+ mcp = FastMCP("code-map", instructions=_INSTRUCTIONS)
61
+
62
+ # ~80 k chars keeps well under the 25 k token MCP output limit.
63
+ OUTPUT_CHAR_LIMIT = 80_000
64
+
65
+ # Map list keys present in the serialisable maps dict.
66
+ _MAP_TYPES = (
67
+ "structural",
68
+ "runtime",
69
+ "data_contract",
70
+ "authority",
71
+ "conflict",
72
+ "hotspot",
73
+ "refactor_boundary",
74
+ "findings",
75
+ )
76
+
77
+ # Per-map-type cap for entries shown in the compact summary view.
78
+ _TOP_ENTRIES_CAP = 10
79
+
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # Serialisation helpers
83
+ # ---------------------------------------------------------------------------
84
+
85
+ def _entry_to_dict(entry: Any) -> Any:
86
+ """Convert a dataclass entry to a plain dict via to_dict() if available."""
87
+ if hasattr(entry, "to_dict"):
88
+ return entry.to_dict()
89
+ if hasattr(entry, "__dict__"):
90
+ return vars(entry)
91
+ return str(entry)
92
+
93
+
94
+ def _repo_maps_to_serialisable(repo_maps: Any) -> dict:
95
+ """Flatten a RepoMaps instance into a plain dict of lists."""
96
+ if repo_maps is None:
97
+ return {"missing": True}
98
+ if getattr(repo_maps, "missing", False):
99
+ return {
100
+ "missing": True,
101
+ "note": "maps directory not found - run start_code_map first",
102
+ }
103
+ return {
104
+ "missing": False,
105
+ "schema_version": getattr(repo_maps, "schema_version", "unknown"),
106
+ "structural": [_entry_to_dict(e) for e in (repo_maps.structural or ())],
107
+ "runtime": [_entry_to_dict(e) for e in (repo_maps.runtime or ())],
108
+ "data_contract": [_entry_to_dict(e) for e in (repo_maps.data_contract or ())],
109
+ "authority": [_entry_to_dict(e) for e in (repo_maps.authority or ())],
110
+ "conflict": [_entry_to_dict(e) for e in (repo_maps.conflict or ())],
111
+ "hotspot": [_entry_to_dict(e) for e in (repo_maps.hotspot or ())],
112
+ "refactor_boundary": [
113
+ _entry_to_dict(e) for e in (repo_maps.refactor_boundary or ())
114
+ ],
115
+ "findings": [_entry_to_dict(e) for e in (repo_maps.findings or ())],
116
+ }
117
+
118
+
119
+ def _paginate_json(data: Any, page: int, page_size_chars: int) -> dict:
120
+ """Serialise *data* to JSON and return a page slice with metadata."""
121
+ full_json = json.dumps(data, default=str, indent=2)
122
+ total = len(full_json)
123
+ start_char = page * page_size_chars
124
+ end_char = start_char + page_size_chars
125
+ return {
126
+ "payload": full_json[start_char:end_char],
127
+ "truncated": end_char < total,
128
+ "total_chars": total,
129
+ "page": page,
130
+ "total_pages": (total + page_size_chars - 1) // page_size_chars,
131
+ }
132
+
133
+
134
+ # ---------------------------------------------------------------------------
135
+ # Summary builder (Feature 2 - summary-first map results)
136
+ # ---------------------------------------------------------------------------
137
+
138
+ def _compact_map_entry(entry: Any) -> dict:
139
+ """Project a map entry down to a few compact fields for the summary.
140
+
141
+ Map types use different field names for an entry's identity:
142
+ structural->file, data_contract->entity, hotspot->target, runtime->node,
143
+ conflict->subject, findings->title, refactor_boundary->boundary_id. Pull the
144
+ first present so the summary is MEANINGFUL for every map (not all-null).
145
+ """
146
+ if not isinstance(entry, dict):
147
+ return {"value": str(entry)}
148
+ name = (
149
+ entry.get("name") or entry.get("entity") or entry.get("target")
150
+ or entry.get("node") or entry.get("subject") or entry.get("title")
151
+ or entry.get("boundary_id") or entry.get("conflict_id")
152
+ or entry.get("finding_id") or entry.get("authority_domain")
153
+ )
154
+ file = (
155
+ entry.get("file") or entry.get("defined_in")
156
+ or entry.get("canonical_schema") or entry.get("canonical_owner")
157
+ or entry.get("path")
158
+ )
159
+ compact: dict[str, Any] = {"name": name, "file": file}
160
+ if entry.get("line") is not None:
161
+ compact["line"] = entry.get("line")
162
+ # One signal metric if present (varies by map type).
163
+ for metric in ("hotspot_score", "severity", "size", "complexity", "score", "count"):
164
+ if entry.get(metric) is not None:
165
+ compact[metric] = entry[metric]
166
+ break
167
+ return compact
168
+
169
+
170
+ def _build_map_summary(maps_data: dict) -> dict:
171
+ """Build a compact summary of serialised repo maps.
172
+
173
+ Args:
174
+ maps_data: Output of ``_repo_maps_to_serialisable`` (a dict with one
175
+ list per map type).
176
+
177
+ Returns:
178
+ A dict with ``by_map_type`` (per-type counts), ``top_entries``
179
+ (up to ``_TOP_ENTRIES_CAP`` compact entries per non-empty type),
180
+ ``schema_version``, ``missing`` and a ``hint``.
181
+ """
182
+ by_map_type: dict[str, int] = {}
183
+ top_entries: dict[str, list[dict]] = {}
184
+ for map_type in _MAP_TYPES:
185
+ entries = maps_data.get(map_type) or []
186
+ by_map_type[map_type] = len(entries)
187
+ if entries:
188
+ top_entries[map_type] = [
189
+ _compact_map_entry(e) for e in entries[:_TOP_ENTRIES_CAP]
190
+ ]
191
+
192
+ hint = (
193
+ "Compact map summary. For all entries of one map call "
194
+ "get_code_map_results with map='<type>' (e.g. 'structural'), or "
195
+ "view='full' for every map. Paging via page=."
196
+ )
197
+
198
+ return {
199
+ "missing": bool(maps_data.get("missing", False)),
200
+ "schema_version": maps_data.get("schema_version", "unknown"),
201
+ "by_map_type": by_map_type,
202
+ "top_entries": top_entries,
203
+ "hint": hint,
204
+ }
205
+
206
+
207
+ # ---------------------------------------------------------------------------
208
+ # Internal: path-preserving wrapper around run_map_build
209
+ # ---------------------------------------------------------------------------
210
+
211
+ def _run_map_build_with_path(path: str, map: str = "all", max_files: int = 800) -> dict:
212
+ """Run the map build and bundle the project path into the return value
213
+ so that get_code_map_results can call load_repo_maps without extra args.
214
+
215
+ Anti-hang file-COUNT guard (mirrors the forensic guard): the map build does
216
+ per-file AST/tree-sitter work that scales with file count, so a repo with
217
+ thousands of files can hang the machine. ``run_map_build`` returns a bare int
218
+ exit code, so the guard is threaded HERE (the MCP wrapper that already
219
+ returns a dict consumed by ``get_code_map_results``) rather than mutating
220
+ that int contract. When the collected source-file count exceeds ``max_files``
221
+ we do NOT build maps; we return a FAST structured ``too_many_files`` dict
222
+ (count + top sub-dirs + a suggestion to narrow scope).
223
+ """
224
+ from vigil_mapper.map_common import iter_source_files
225
+ from vigil_mapper._file_count_guard import build_too_many_files_meta
226
+
227
+ project_dir = Path(path)
228
+ # Cheap pass: collect the same source files the build would, but only to
229
+ # COUNT + group by top-level sub-dir. No parsing happens here.
230
+ root = project_dir.resolve()
231
+ rel_paths: list[str] = []
232
+ for f in iter_source_files(project_dir):
233
+ try:
234
+ rel_paths.append(f.resolve().relative_to(root).as_posix())
235
+ except ValueError:
236
+ rel_paths.append(f.name)
237
+
238
+ if len(rel_paths) > max_files:
239
+ meta = build_too_many_files_meta(
240
+ rel_paths, max_files, entry_call="start_code_map"
241
+ )
242
+ # exit_code 0: skipping is a controlled outcome, not an error.
243
+ return {"exit_code": 0, "_path": path, **meta}
244
+
245
+ exit_code = run_map_build(project_dir, map=map, timeout_s=300)
246
+ return {"exit_code": exit_code, "_path": path}
247
+
248
+
249
+ # ---------------------------------------------------------------------------
250
+ # MCP tools
251
+ # ---------------------------------------------------------------------------
252
+
253
+ @mcp.tool()
254
+ def start_code_map(path: str = "", map: str = "all", max_files: int = 800) -> dict:
255
+ """Start a background code-map build job for the given project path.
256
+
257
+ Args:
258
+ path: Absolute path to the project root directory. When empty/omitted
259
+ the project root is auto-detected by walking up from the current
260
+ working directory for a ``.git`` / ``pyproject.toml`` /
261
+ ``package.json`` marker (falling back to cwd). The chosen
262
+ directory is returned as ``resolved_path``.
263
+ map: Map type to build - "all" (default) or a specific map name
264
+ recognised by vigil_mapper (e.g. "structural").
265
+ max_files: Anti-hang ceiling on the collected source-file count
266
+ (default 800). Above it the build is SKIPPED and
267
+ get_code_map_results reports skipped_reason="too_many_files" with
268
+ top_subdirs + a suggestion to scan a submodule; raise it to force
269
+ a full build of a huge repo.
270
+
271
+ Returns:
272
+ {"job_id": str | None, "status": "running" | "busy",
273
+ "resolved_path": str, ...}
274
+ When status is "busy" the server is at max concurrent jobs; retry later.
275
+ """
276
+ # Auto-target: resolve only when no explicit path was given.
277
+ if path:
278
+ resolved_path = path
279
+ else:
280
+ resolved_path = _paths._resolve_project_root(None)
281
+
282
+ # project_dir enables disk-backed persistence so results survive a server
283
+ # restart; get_code_map_status/results then resolve by job_id from disk.
284
+ started = _jobs.start(
285
+ _run_map_build_with_path, resolved_path, map=map, max_files=max_files,
286
+ project_dir=resolved_path,
287
+ )
288
+ started["resolved_path"] = resolved_path
289
+ return started
290
+
291
+
292
+ @mcp.tool()
293
+ def get_code_map_status(job_id: str) -> dict:
294
+ """Poll the status of a code-map build job.
295
+
296
+ Args:
297
+ job_id: Job ID returned by start_code_map.
298
+
299
+ Returns:
300
+ {"job_id": str, "status": "running" | "done" | "error" | "cancelled" | "not_found"}
301
+ """
302
+ return _jobs.status(job_id)
303
+
304
+
305
+ @mcp.tool()
306
+ def get_code_map_results(
307
+ job_id: str,
308
+ view: str = "summary",
309
+ map: str = "",
310
+ page: int = 0,
311
+ page_size_chars: int = OUTPUT_CHAR_LIMIT,
312
+ ) -> dict:
313
+ """Retrieve results of a completed code-map build.
314
+
315
+ Three modes (loads maps written to disk by run_map_build):
316
+ * ``view='summary'`` (default) - per-map-type counts + the top entries
317
+ per map. Compact; fits the MCP context budget. Use this first.
318
+ * ``map='<type>'`` - every entry of a single map (e.g. 'structural'),
319
+ paginated. Takes precedence over ``view``.
320
+ * ``view='full'`` - every entry of every map, paginated.
321
+
322
+ Args:
323
+ job_id: Job ID returned by start_code_map.
324
+ view: "summary" (default) or "full".
325
+ map: A single map type to return in full (e.g.
326
+ "structural"). Empty = honour ``view``.
327
+ page: Zero-based page index (each page ≈ page_size_chars chars).
328
+ page_size_chars: Max chars per page (default 80 000 ≈ 25 k tokens).
329
+
330
+ Returns:
331
+ dict with "job_id", "status", "view", "exit_code", "payload"
332
+ (JSON string), "truncated" (bool), "total_chars", "page", "total_pages".
333
+ """
334
+ r = _jobs.result(job_id)
335
+ status = r.get("status")
336
+
337
+ if status in ("running", "not_found"):
338
+ return {
339
+ "job_id": job_id, "status": status,
340
+ "payload": None, "truncated": False, "total_chars": 0,
341
+ }
342
+ if status == "cancelled":
343
+ return {
344
+ "job_id": job_id, "status": "cancelled",
345
+ "payload": None, "truncated": False, "total_chars": 0,
346
+ }
347
+ if status == "error":
348
+ return {
349
+ "job_id": job_id, "status": "error", "error": r.get("error"),
350
+ "payload": None, "truncated": False, "total_chars": 0,
351
+ }
352
+
353
+ # status == "done"
354
+ inner = r.get("result") or {}
355
+ exit_code = inner.get("exit_code") if isinstance(inner, dict) else inner
356
+ path_str = inner.get("_path") if isinstance(inner, dict) else None
357
+
358
+ # Anti-hang guard fired: the build was skipped because the repo has too many
359
+ # files. Surface the structured skip (no maps were built) instead of an
360
+ # empty/"missing" maps payload, so the caller learns to narrow scope.
361
+ if isinstance(inner, dict) and inner.get("skipped_reason") == "too_many_files":
362
+ skip_payload = {
363
+ "exit_code": exit_code,
364
+ "skipped_reason": inner.get("skipped_reason"),
365
+ "file_count": inner.get("file_count"),
366
+ "max_files": inner.get("max_files"),
367
+ "top_subdirs": inner.get("top_subdirs"),
368
+ "suggestion": inner.get("suggestion"),
369
+ }
370
+ page_data = _paginate_json(skip_payload, page=page, page_size_chars=page_size_chars)
371
+ return {
372
+ "job_id": job_id, "status": "done", "view": "skipped",
373
+ "exit_code": exit_code, **page_data,
374
+ }
375
+
376
+ if path_str:
377
+ try:
378
+ repo_maps = load_repo_maps(Path(path_str))
379
+ maps_data = _repo_maps_to_serialisable(repo_maps)
380
+ except Exception as exc:
381
+ maps_data = {"error": str(exc)}
382
+ else:
383
+ # _repo_maps_to_serialisable is monkeypatchable; call it so tests that
384
+ # patch it can inject fake maps even when no path is available.
385
+ maps_data = _repo_maps_to_serialisable(None)
386
+
387
+ # Decide the projection of maps_data based on map / view.
388
+ effective_view = "summary"
389
+ if map and map != "all":
390
+ # Single-map view: only the requested map type's entries.
391
+ rendered_maps: dict = {map: maps_data.get(map, [])}
392
+ effective_view = f"map:{map}"
393
+ elif view == "full":
394
+ rendered_maps = maps_data
395
+ effective_view = "full"
396
+ else:
397
+ rendered_maps = _build_map_summary(maps_data)
398
+ effective_view = "summary"
399
+
400
+ full_data = {"exit_code": exit_code, "maps": rendered_maps}
401
+ page_data = _paginate_json(full_data, page=page, page_size_chars=page_size_chars)
402
+
403
+ return {
404
+ "job_id": job_id, "status": "done", "view": effective_view,
405
+ "exit_code": exit_code, **page_data,
406
+ }
407
+
408
+
409
+ @mcp.tool()
410
+ def load_code_map_by_path(
411
+ path: str,
412
+ page: int = 0,
413
+ page_size_chars: int = OUTPUT_CHAR_LIMIT,
414
+ ) -> dict:
415
+ """Load previously built maps from disk for a given project path (no job needed).
416
+
417
+ Useful when maps were built in a prior session, or to re-read results.
418
+
419
+ Args:
420
+ path: Absolute path to the project root.
421
+ page: Zero-based page index.
422
+ page_size_chars: Max chars per page.
423
+ """
424
+ try:
425
+ repo_maps = load_repo_maps(Path(path))
426
+ maps_data = _repo_maps_to_serialisable(repo_maps)
427
+ except Exception as exc:
428
+ return {"status": "error", "error": str(exc)}
429
+
430
+ page_data = _paginate_json(maps_data, page=page, page_size_chars=page_size_chars)
431
+ return {"status": "ok", **page_data}
432
+
433
+
434
+ @mcp.tool()
435
+ def cancel_code_map(job_id: str) -> dict:
436
+ """Cancel a running code-map build job.
437
+
438
+ Args:
439
+ job_id: Job ID returned by start_code_map.
440
+
441
+ Returns:
442
+ {"job_id": str, "cancelled": bool, ...}
443
+ """
444
+ return _jobs.cancel(job_id)
445
+
446
+
447
+ def main() -> None:
448
+ mcp.run()
449
+
450
+
451
+ if __name__ == "__main__":
452
+ main()