codemap-core 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.
- codemap/__init__.py +7 -0
- codemap/cli/__init__.py +3 -0
- codemap/cli/_common.py +90 -0
- codemap/cli/commands/__init__.py +3 -0
- codemap/cli/commands/callees.py +102 -0
- codemap/cli/commands/callers.py +107 -0
- codemap/cli/commands/config.py +78 -0
- codemap/cli/commands/diagnostics.py +142 -0
- codemap/cli/commands/doctor.py +158 -0
- codemap/cli/commands/get.py +93 -0
- codemap/cli/commands/index.py +725 -0
- codemap/cli/commands/routes.py +104 -0
- codemap/cli/commands/search.py +78 -0
- codemap/cli/commands/trace.py +179 -0
- codemap/cli/main.py +140 -0
- codemap/cli/renderers/__init__.py +3 -0
- codemap/cli/renderers/json.py +32 -0
- codemap/cli/renderers/text.py +24 -0
- codemap/config/__init__.py +31 -0
- codemap/config/loader.py +96 -0
- codemap/config/schema.py +122 -0
- codemap/core/__init__.py +7 -0
- codemap/core/bridge/__init__.py +8 -0
- codemap/core/bridge/base.py +38 -0
- codemap/core/bridge/http_route.py +374 -0
- codemap/core/bridge/python_cross_module.py +120 -0
- codemap/core/bridge/registry.py +117 -0
- codemap/core/graph.py +183 -0
- codemap/core/models.py +299 -0
- codemap/core/store.py +78 -0
- codemap/core/symbol.py +314 -0
- codemap/diagnostics/__init__.py +3 -0
- codemap/diagnostics/exit_codes.py +30 -0
- codemap/diagnostics/logging.py +65 -0
- codemap/diagnostics/progress.py +68 -0
- codemap/indexers/__init__.py +9 -0
- codemap/indexers/_example_lang.py +135 -0
- codemap/indexers/base.py +77 -0
- codemap/indexers/python.py +577 -0
- codemap/indexers/registry.py +104 -0
- codemap/io/__init__.py +8 -0
- codemap/io/atomic.py +97 -0
- codemap/io/base.py +12 -0
- codemap/io/json_store.py +433 -0
- codemap/io/lock.py +87 -0
- codemap/io/manifest.py +90 -0
- codemap/mcp/__init__.py +3 -0
- codemap_core-0.1.0.dist-info/METADATA +480 -0
- codemap_core-0.1.0.dist-info/RECORD +52 -0
- codemap_core-0.1.0.dist-info/WHEEL +4 -0
- codemap_core-0.1.0.dist-info/entry_points.txt +10 -0
- codemap_core-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""``codemap routes`` — list HTTP routes produced by the http_route bridge."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from codemap.cli._common import format_location, open_store_or_exit
|
|
12
|
+
from codemap.cli.renderers import json as json_renderer
|
|
13
|
+
from codemap.cli.renderers import text
|
|
14
|
+
from codemap.core.models import Symbol
|
|
15
|
+
from codemap.core.symbol import SymbolID
|
|
16
|
+
from codemap.io.json_store import JsonStore
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def register(app: typer.Typer) -> None:
|
|
20
|
+
@app.command("routes")
|
|
21
|
+
def routes(
|
|
22
|
+
ctx: typer.Context,
|
|
23
|
+
method_filter: Annotated[
|
|
24
|
+
str | None,
|
|
25
|
+
typer.Option("--method", "-m", help="Filter by HTTP method (case-insensitive)."),
|
|
26
|
+
] = None,
|
|
27
|
+
project: Annotated[
|
|
28
|
+
Path,
|
|
29
|
+
typer.Option(
|
|
30
|
+
"--project",
|
|
31
|
+
"-p",
|
|
32
|
+
file_okay=False,
|
|
33
|
+
dir_okay=True,
|
|
34
|
+
help="Project root containing `.codemap/`.",
|
|
35
|
+
),
|
|
36
|
+
] = Path("."),
|
|
37
|
+
) -> None:
|
|
38
|
+
"""List all HTTP routes (method, path → server handler)."""
|
|
39
|
+
as_json: bool = ctx.obj["json_output"]
|
|
40
|
+
wanted = method_filter.upper() if method_filter else None
|
|
41
|
+
with open_store_or_exit(project) as store:
|
|
42
|
+
all_routes = [r for r in store.iter_routes() if wanted is None or r.method == wanted]
|
|
43
|
+
entries = [(r, list(_resolve_aliases(r.symbol_id, store))) for r in all_routes]
|
|
44
|
+
|
|
45
|
+
if as_json:
|
|
46
|
+
json_renderer.emit(
|
|
47
|
+
"routes",
|
|
48
|
+
{
|
|
49
|
+
"method_filter": wanted,
|
|
50
|
+
"results": [
|
|
51
|
+
{
|
|
52
|
+
"method": r.method,
|
|
53
|
+
"path": r.path,
|
|
54
|
+
"route_id": str(r.symbol_id),
|
|
55
|
+
"handlers": [
|
|
56
|
+
{
|
|
57
|
+
"id": str(s.id),
|
|
58
|
+
"kind": s.kind,
|
|
59
|
+
"language": s.language,
|
|
60
|
+
"file": str(s.file),
|
|
61
|
+
"line": s.range.start_line,
|
|
62
|
+
}
|
|
63
|
+
for s in handlers
|
|
64
|
+
],
|
|
65
|
+
}
|
|
66
|
+
for r, handlers in entries
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
)
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
cons = text.console()
|
|
73
|
+
if not entries:
|
|
74
|
+
cons.print(
|
|
75
|
+
"[yellow]No routes recorded.[/yellow] "
|
|
76
|
+
"If your indexer doesn't emit `http_route` metadata, "
|
|
77
|
+
"the bridge has nothing to link."
|
|
78
|
+
)
|
|
79
|
+
return
|
|
80
|
+
rows: list[list[str]] = []
|
|
81
|
+
for route, handlers in entries:
|
|
82
|
+
handler_str = (
|
|
83
|
+
"\n".join(format_location(s.file, s.range.start_line) for s in handlers)
|
|
84
|
+
if handlers
|
|
85
|
+
else "(unresolved)"
|
|
86
|
+
)
|
|
87
|
+
rows.append([route.method, route.path, handler_str])
|
|
88
|
+
cons.print(text.table("HTTP routes", ["method", "path", "handler"], rows))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _resolve_aliases(route_id: SymbolID, store: JsonStore) -> Iterator[Symbol]:
|
|
92
|
+
"""Return the Symbol objects each alias.targets points at, for routes."""
|
|
93
|
+
for alias in store.iter_aliases():
|
|
94
|
+
if str(alias.source) != str(route_id):
|
|
95
|
+
continue
|
|
96
|
+
if alias.producer != "http_route":
|
|
97
|
+
continue
|
|
98
|
+
for target_id in alias.targets:
|
|
99
|
+
sym = store.get(target_id)
|
|
100
|
+
if sym is not None:
|
|
101
|
+
yield sym
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
__all__ = ["register"]
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""``codemap search`` — keyword search across the local index."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from codemap.cli._common import format_location, open_store_or_exit
|
|
11
|
+
from codemap.cli.renderers import json as json_renderer
|
|
12
|
+
from codemap.cli.renderers import text
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def register(app: typer.Typer) -> None:
|
|
16
|
+
@app.command("search")
|
|
17
|
+
def search(
|
|
18
|
+
ctx: typer.Context,
|
|
19
|
+
query: Annotated[str, typer.Argument(help="Substring to look for.")],
|
|
20
|
+
limit: Annotated[
|
|
21
|
+
int,
|
|
22
|
+
typer.Option("--limit", "-n", help="Maximum number of hits."),
|
|
23
|
+
] = 10,
|
|
24
|
+
project: Annotated[
|
|
25
|
+
Path,
|
|
26
|
+
typer.Option(
|
|
27
|
+
"--project",
|
|
28
|
+
"-p",
|
|
29
|
+
file_okay=False,
|
|
30
|
+
dir_okay=True,
|
|
31
|
+
help="Project root containing `.codemap/`.",
|
|
32
|
+
),
|
|
33
|
+
] = Path("."),
|
|
34
|
+
) -> None:
|
|
35
|
+
"""Keyword search across symbol IDs, signatures, and docstrings."""
|
|
36
|
+
as_json: bool = ctx.obj["json_output"]
|
|
37
|
+
with open_store_or_exit(project) as store:
|
|
38
|
+
hits = store.search(query, limit=limit)
|
|
39
|
+
|
|
40
|
+
if as_json:
|
|
41
|
+
json_renderer.emit(
|
|
42
|
+
"search",
|
|
43
|
+
{
|
|
44
|
+
"query": query,
|
|
45
|
+
"limit": limit,
|
|
46
|
+
"results": [
|
|
47
|
+
{
|
|
48
|
+
"id": str(h.symbol.id),
|
|
49
|
+
"kind": h.symbol.kind,
|
|
50
|
+
"language": h.symbol.language,
|
|
51
|
+
"file": str(h.symbol.file),
|
|
52
|
+
"line": h.symbol.range.start_line,
|
|
53
|
+
"signature": h.symbol.signature,
|
|
54
|
+
"score": h.score,
|
|
55
|
+
"source": h.source,
|
|
56
|
+
}
|
|
57
|
+
for h in hits
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
)
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
cons = text.console()
|
|
64
|
+
if not hits:
|
|
65
|
+
cons.print(f"[yellow]No matches for[/yellow] {query!r}")
|
|
66
|
+
return
|
|
67
|
+
rows = [
|
|
68
|
+
[
|
|
69
|
+
h.symbol.kind,
|
|
70
|
+
format_location(h.symbol.file, h.symbol.range.start_line),
|
|
71
|
+
h.symbol.signature or str(h.symbol.id),
|
|
72
|
+
]
|
|
73
|
+
for h in hits
|
|
74
|
+
]
|
|
75
|
+
cons.print(text.table(f"Search: {query}", ["kind", "location", "symbol"], rows))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
__all__ = ["register"]
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""``codemap trace --from <id> [--to <id>]`` — walk or path-find through edges."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated, Any
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.tree import Tree
|
|
10
|
+
|
|
11
|
+
from codemap.cli._common import (
|
|
12
|
+
format_location,
|
|
13
|
+
open_store_or_exit,
|
|
14
|
+
parse_symbol_id_or_exit,
|
|
15
|
+
)
|
|
16
|
+
from codemap.cli.renderers import json as json_renderer
|
|
17
|
+
from codemap.cli.renderers import text
|
|
18
|
+
from codemap.core.graph import ChainNode, shortest_path, walk_chain
|
|
19
|
+
from codemap.diagnostics.exit_codes import ExitCode
|
|
20
|
+
from codemap.io.json_store import JsonStore
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def register(app: typer.Typer) -> None:
|
|
24
|
+
@app.command("trace")
|
|
25
|
+
def trace(
|
|
26
|
+
ctx: typer.Context,
|
|
27
|
+
from_: Annotated[
|
|
28
|
+
str,
|
|
29
|
+
typer.Option("--from", "-f", help="Starting SymbolID."),
|
|
30
|
+
],
|
|
31
|
+
to: Annotated[
|
|
32
|
+
str | None,
|
|
33
|
+
typer.Option(
|
|
34
|
+
"--to",
|
|
35
|
+
"-t",
|
|
36
|
+
help="Optional destination SymbolID — switches to shortest-path mode.",
|
|
37
|
+
),
|
|
38
|
+
] = None,
|
|
39
|
+
depth: Annotated[
|
|
40
|
+
int,
|
|
41
|
+
typer.Option("--depth", "-d", min=1, help="Maximum hops."),
|
|
42
|
+
] = 5,
|
|
43
|
+
project: Annotated[
|
|
44
|
+
Path,
|
|
45
|
+
typer.Option(
|
|
46
|
+
"--project",
|
|
47
|
+
"-p",
|
|
48
|
+
file_okay=False,
|
|
49
|
+
dir_okay=True,
|
|
50
|
+
help="Project root containing `.codemap/`.",
|
|
51
|
+
),
|
|
52
|
+
] = Path("."),
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Walk the call chain downstream from ``--from`` (or find the path to ``--to``)."""
|
|
55
|
+
as_json: bool = ctx.obj["json_output"]
|
|
56
|
+
start = parse_symbol_id_or_exit(from_)
|
|
57
|
+
with open_store_or_exit(project) as store:
|
|
58
|
+
if to is None:
|
|
59
|
+
root = walk_chain(start, store, max_depth=depth)
|
|
60
|
+
if as_json:
|
|
61
|
+
json_renderer.emit("trace", _chain_to_json(root, store))
|
|
62
|
+
else:
|
|
63
|
+
_render_chain_text(root, store)
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
end = parse_symbol_id_or_exit(to)
|
|
67
|
+
path = shortest_path(start, end, store, max_depth=depth)
|
|
68
|
+
if path is None:
|
|
69
|
+
if as_json:
|
|
70
|
+
json_renderer.emit(
|
|
71
|
+
"trace",
|
|
72
|
+
{"from": str(start), "to": str(end), "found": False, "path": []},
|
|
73
|
+
)
|
|
74
|
+
else:
|
|
75
|
+
text.console(stderr=True).print(
|
|
76
|
+
f"[yellow]No path found within depth {depth}[/yellow]"
|
|
77
|
+
)
|
|
78
|
+
raise typer.Exit(code=int(ExitCode.GENERIC_ERROR))
|
|
79
|
+
|
|
80
|
+
if as_json:
|
|
81
|
+
json_renderer.emit(
|
|
82
|
+
"trace",
|
|
83
|
+
{
|
|
84
|
+
"from": str(start),
|
|
85
|
+
"to": str(end),
|
|
86
|
+
"found": True,
|
|
87
|
+
"path": _path_to_json(path, store, start),
|
|
88
|
+
},
|
|
89
|
+
)
|
|
90
|
+
else:
|
|
91
|
+
_render_path_text(start, path, store)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
# Rendering
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _chain_to_json(node: ChainNode, store: JsonStore) -> dict[str, Any]:
|
|
100
|
+
sym = store.get(node.symbol_id)
|
|
101
|
+
return {
|
|
102
|
+
"id": str(node.symbol_id),
|
|
103
|
+
"kind": sym.kind if sym else "external",
|
|
104
|
+
"file": str(sym.file) if sym else None,
|
|
105
|
+
"line": sym.range.start_line if sym else None,
|
|
106
|
+
"incoming_edge": (
|
|
107
|
+
{
|
|
108
|
+
"kind": node.incoming_edge.kind,
|
|
109
|
+
"confidence": node.incoming_edge.confidence,
|
|
110
|
+
}
|
|
111
|
+
if node.incoming_edge is not None
|
|
112
|
+
else None
|
|
113
|
+
),
|
|
114
|
+
"depth": node.depth,
|
|
115
|
+
"children": [_chain_to_json(c, store) for c in node.children],
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _path_to_json(
|
|
120
|
+
path: list[Any],
|
|
121
|
+
store: JsonStore,
|
|
122
|
+
start: Any,
|
|
123
|
+
) -> list[dict[str, Any]]:
|
|
124
|
+
out: list[dict[str, Any]] = []
|
|
125
|
+
current = start
|
|
126
|
+
out.append(_node_payload(current, store, edge=None))
|
|
127
|
+
for edge in path:
|
|
128
|
+
out.append(_node_payload(edge.target, store, edge=edge))
|
|
129
|
+
current = edge.target
|
|
130
|
+
return out
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _node_payload(sid: Any, store: JsonStore, edge: Any) -> dict[str, Any]:
|
|
134
|
+
sym = store.get(sid)
|
|
135
|
+
return {
|
|
136
|
+
"id": str(sid),
|
|
137
|
+
"kind": sym.kind if sym else "external",
|
|
138
|
+
"file": str(sym.file) if sym else None,
|
|
139
|
+
"line": sym.range.start_line if sym else None,
|
|
140
|
+
"incoming_edge": (
|
|
141
|
+
{"kind": edge.kind, "confidence": edge.confidence} if edge is not None else None
|
|
142
|
+
),
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _render_chain_text(node: ChainNode, store: JsonStore) -> None:
|
|
147
|
+
tree = Tree(_label_for(node.symbol_id, store, edge=None))
|
|
148
|
+
for child in node.children:
|
|
149
|
+
_attach(tree, child, store)
|
|
150
|
+
text.console().print(tree)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _attach(parent: Tree, node: ChainNode, store: JsonStore) -> None:
|
|
154
|
+
branch = parent.add(_label_for(node.symbol_id, store, edge=node.incoming_edge))
|
|
155
|
+
for child in node.children:
|
|
156
|
+
_attach(branch, child, store)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _render_path_text(start: Any, path: list[Any], store: JsonStore) -> None:
|
|
160
|
+
cons = text.console()
|
|
161
|
+
cons.print(_label_for(start, store, edge=None))
|
|
162
|
+
for edge in path:
|
|
163
|
+
cons.print(" " + _label_for(edge.target, store, edge=edge))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _label_for(sid: Any, store: JsonStore, edge: Any) -> str:
|
|
167
|
+
sym = store.get(sid)
|
|
168
|
+
if sym is not None:
|
|
169
|
+
head = f"[bold]{format_location(sym.file, sym.range.start_line)}[/bold]"
|
|
170
|
+
signature = sym.signature or str(sid)
|
|
171
|
+
else:
|
|
172
|
+
head = "[dim](external)[/dim]"
|
|
173
|
+
signature = str(sid)
|
|
174
|
+
if edge is None:
|
|
175
|
+
return f"{head} {signature}"
|
|
176
|
+
return f"{head} {signature} [{edge.kind}/{edge.confidence}]"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
__all__ = ["register"]
|
codemap/cli/main.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Typer entry point: ``codemap``.
|
|
2
|
+
|
|
3
|
+
Reads global options, sets up logging, dispatches to per-command modules.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
import traceback
|
|
11
|
+
from typing import Annotated
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
from codemap import __version__
|
|
16
|
+
from codemap.cli.commands import callees as callees_cmd
|
|
17
|
+
from codemap.cli.commands import callers as callers_cmd
|
|
18
|
+
from codemap.cli.commands import config as config_cmd
|
|
19
|
+
from codemap.cli.commands import diagnostics as diagnostics_cmd
|
|
20
|
+
from codemap.cli.commands import doctor as doctor_cmd
|
|
21
|
+
from codemap.cli.commands import get as get_cmd
|
|
22
|
+
from codemap.cli.commands import index as index_cmd
|
|
23
|
+
from codemap.cli.commands import routes as routes_cmd
|
|
24
|
+
from codemap.cli.commands import search as search_cmd
|
|
25
|
+
from codemap.cli.commands import trace as trace_cmd
|
|
26
|
+
from codemap.cli.renderers import text
|
|
27
|
+
from codemap.diagnostics.exit_codes import ExitCode
|
|
28
|
+
from codemap.diagnostics.logging import LogFormat, configure_logging
|
|
29
|
+
|
|
30
|
+
app = typer.Typer(
|
|
31
|
+
name="codemap",
|
|
32
|
+
help="Language-neutral code index for AI agents.",
|
|
33
|
+
no_args_is_help=True,
|
|
34
|
+
add_completion=False,
|
|
35
|
+
rich_markup_mode="rich",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _version_callback(value: bool) -> None:
|
|
40
|
+
if value:
|
|
41
|
+
typer.echo(__version__)
|
|
42
|
+
raise typer.Exit()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@app.callback()
|
|
46
|
+
def _root(
|
|
47
|
+
ctx: typer.Context,
|
|
48
|
+
verbose: Annotated[
|
|
49
|
+
int,
|
|
50
|
+
typer.Option(
|
|
51
|
+
"-v",
|
|
52
|
+
"--verbose",
|
|
53
|
+
count=True,
|
|
54
|
+
help="Increase verbosity (-v: INFO, -vv: DEBUG).",
|
|
55
|
+
),
|
|
56
|
+
] = 0,
|
|
57
|
+
log_format: Annotated[
|
|
58
|
+
str,
|
|
59
|
+
typer.Option(
|
|
60
|
+
"--log-format",
|
|
61
|
+
help="Log format: text or json.",
|
|
62
|
+
case_sensitive=False,
|
|
63
|
+
),
|
|
64
|
+
] = "text",
|
|
65
|
+
json_output: Annotated[
|
|
66
|
+
bool,
|
|
67
|
+
typer.Option(
|
|
68
|
+
"--json",
|
|
69
|
+
help="Emit machine-readable JSON instead of human-readable text.",
|
|
70
|
+
),
|
|
71
|
+
] = False,
|
|
72
|
+
version: Annotated[
|
|
73
|
+
bool,
|
|
74
|
+
typer.Option(
|
|
75
|
+
"--version",
|
|
76
|
+
help="Print version and exit.",
|
|
77
|
+
callback=_version_callback,
|
|
78
|
+
is_eager=True,
|
|
79
|
+
),
|
|
80
|
+
] = False,
|
|
81
|
+
) -> None:
|
|
82
|
+
"""Global flags and per-invocation state."""
|
|
83
|
+
fmt: LogFormat = "json" if log_format.lower() == "json" else "text"
|
|
84
|
+
configure_logging(verbose, log_format=fmt)
|
|
85
|
+
ctx.obj = {
|
|
86
|
+
"verbose": verbose,
|
|
87
|
+
"json_output": json_output,
|
|
88
|
+
"log_format": fmt,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# Register subcommands
|
|
93
|
+
doctor_cmd.register(app)
|
|
94
|
+
index_cmd.register(app)
|
|
95
|
+
search_cmd.register(app)
|
|
96
|
+
get_cmd.register(app)
|
|
97
|
+
callers_cmd.register(app)
|
|
98
|
+
callees_cmd.register(app)
|
|
99
|
+
trace_cmd.register(app)
|
|
100
|
+
routes_cmd.register(app)
|
|
101
|
+
config_cmd.register(app)
|
|
102
|
+
diagnostics_cmd.register(app)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
_ISSUE_TRACKER = "https://github.com/qxbyte/codemap/issues"
|
|
106
|
+
_TRACEBACK_ENV_VAR = "CODEMAP_FULL_TRACEBACK"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def main() -> None:
|
|
110
|
+
"""Entry point. Catches anything Typer doesn't and prints a friendly hint.
|
|
111
|
+
|
|
112
|
+
Set ``CODEMAP_FULL_TRACEBACK=1`` to see the original traceback (useful
|
|
113
|
+
when filing a bug).
|
|
114
|
+
"""
|
|
115
|
+
try:
|
|
116
|
+
app()
|
|
117
|
+
except (typer.Exit, SystemExit, KeyboardInterrupt):
|
|
118
|
+
raise
|
|
119
|
+
except Exception as exc: # pragma: no cover - exercised manually
|
|
120
|
+
cons = text.console(stderr=True)
|
|
121
|
+
cons.print(
|
|
122
|
+
f"[bold red]Internal error:[/bold red] "
|
|
123
|
+
f"{type(exc).__name__}: {exc}\n"
|
|
124
|
+
f"This is a bug in CodeMap. Please file an issue at\n"
|
|
125
|
+
f" [cyan]{_ISSUE_TRACKER}[/cyan]\n"
|
|
126
|
+
f"and include the output of `codemap --version` and the command "
|
|
127
|
+
f"you ran."
|
|
128
|
+
)
|
|
129
|
+
if os.environ.get(_TRACEBACK_ENV_VAR):
|
|
130
|
+
cons.print("[dim]" + traceback.format_exc() + "[/dim]")
|
|
131
|
+
else:
|
|
132
|
+
cons.print(
|
|
133
|
+
f"[dim]Set {_TRACEBACK_ENV_VAR}=1 to see the full "
|
|
134
|
+
f"traceback when filing the issue.[/dim]"
|
|
135
|
+
)
|
|
136
|
+
sys.exit(int(ExitCode.INTERNAL_BUG))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
if __name__ == "__main__": # pragma: no cover
|
|
140
|
+
main()
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""JSON renderer for CLI output (the canonical machine-readable format).
|
|
2
|
+
|
|
3
|
+
Every CLI command that supports ``--json`` ends here. The envelope is
|
|
4
|
+
versioned (``schema_version``) so changes are observable.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import sys
|
|
11
|
+
from typing import Any, TextIO
|
|
12
|
+
|
|
13
|
+
from codemap.core.models import SCHEMA_VERSION
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def emit(command: str, payload: Any, *, stream: TextIO | None = None) -> None:
|
|
17
|
+
"""Print a single JSON envelope and a trailing newline.
|
|
18
|
+
|
|
19
|
+
``stream`` is resolved at call time so the renderer respects any
|
|
20
|
+
``sys.stdout`` redirection done by test runners (``CliRunner`` etc.).
|
|
21
|
+
"""
|
|
22
|
+
envelope = {
|
|
23
|
+
"schema_version": SCHEMA_VERSION,
|
|
24
|
+
"command": command,
|
|
25
|
+
"result": payload,
|
|
26
|
+
}
|
|
27
|
+
s = stream if stream is not None else sys.stdout
|
|
28
|
+
s.write(json.dumps(envelope, ensure_ascii=False, indent=2))
|
|
29
|
+
s.write("\n")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
__all__ = ["emit"]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Human-readable renderers built on rich."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def console(*, stderr: bool = False) -> Console:
|
|
12
|
+
return Console(stderr=stderr)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def table(title: str, columns: list[str], rows: list[list[Any]]) -> Table:
|
|
16
|
+
t = Table(title=title, show_lines=False, header_style="bold")
|
|
17
|
+
for c in columns:
|
|
18
|
+
t.add_column(c)
|
|
19
|
+
for row in rows:
|
|
20
|
+
t.add_row(*[str(x) for x in row])
|
|
21
|
+
return t
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
__all__ = ["console", "table"]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Configuration schema and loader for `.codemap/config.yaml`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from codemap.config.loader import (
|
|
6
|
+
ConfigError,
|
|
7
|
+
dump_config,
|
|
8
|
+
load_config,
|
|
9
|
+
project_config_path,
|
|
10
|
+
user_config_path,
|
|
11
|
+
)
|
|
12
|
+
from codemap.config.schema import (
|
|
13
|
+
BridgesConfig,
|
|
14
|
+
Config,
|
|
15
|
+
IndexConfig,
|
|
16
|
+
IndexersConfig,
|
|
17
|
+
StorageConfig,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"BridgesConfig",
|
|
22
|
+
"Config",
|
|
23
|
+
"ConfigError",
|
|
24
|
+
"IndexConfig",
|
|
25
|
+
"IndexersConfig",
|
|
26
|
+
"StorageConfig",
|
|
27
|
+
"dump_config",
|
|
28
|
+
"load_config",
|
|
29
|
+
"project_config_path",
|
|
30
|
+
"user_config_path",
|
|
31
|
+
]
|
codemap/config/loader.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Load and merge `.codemap/config.yaml` from defaults / user / project."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, cast
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
from pydantic import ValidationError
|
|
11
|
+
|
|
12
|
+
from codemap.config.schema import Config
|
|
13
|
+
|
|
14
|
+
PROJECT_CONFIG_FILENAME = "config.yaml"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ConfigError(ValueError):
|
|
18
|
+
"""Raised when a config file is malformed or fails schema validation."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def user_config_path() -> Path:
|
|
22
|
+
"""Return ``~/.config/codemap/config.yaml`` regardless of whether it exists."""
|
|
23
|
+
return Path.home() / ".config" / "codemap" / PROJECT_CONFIG_FILENAME
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def project_config_path(codemap_dir: Path) -> Path:
|
|
27
|
+
return codemap_dir / PROJECT_CONFIG_FILENAME
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def load_config(codemap_dir: Path | None = None) -> Config:
|
|
31
|
+
"""Load defaults → user-level → project-level config, merged in that order.
|
|
32
|
+
|
|
33
|
+
``codemap_dir`` is the ``.codemap/`` directory; the project-level file
|
|
34
|
+
lives there. Pass ``None`` to skip the project layer (useful for CLI
|
|
35
|
+
invocations against an unindexed directory).
|
|
36
|
+
|
|
37
|
+
Raises :class:`ConfigError` on parse or validation failure. Missing
|
|
38
|
+
files are not errors — they leave the layer at defaults.
|
|
39
|
+
"""
|
|
40
|
+
merged: dict[str, Any] = {}
|
|
41
|
+
_merge_into(merged, _read_yaml(user_config_path()))
|
|
42
|
+
if codemap_dir is not None:
|
|
43
|
+
_merge_into(merged, _read_yaml(project_config_path(codemap_dir)))
|
|
44
|
+
try:
|
|
45
|
+
return Config.model_validate(merged)
|
|
46
|
+
except ValidationError as exc:
|
|
47
|
+
raise ConfigError(
|
|
48
|
+
"config validation failed:\n"
|
|
49
|
+
+ "\n".join(
|
|
50
|
+
f" - {'.'.join(str(p) for p in err['loc'])}: {err['msg']}" for err in exc.errors()
|
|
51
|
+
)
|
|
52
|
+
) from exc
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _read_yaml(path: Path) -> Mapping[str, Any]:
|
|
56
|
+
if not path.exists():
|
|
57
|
+
return {}
|
|
58
|
+
try:
|
|
59
|
+
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
60
|
+
except yaml.YAMLError as exc:
|
|
61
|
+
raise ConfigError(f"failed to parse {path}: {exc}") from exc
|
|
62
|
+
if raw is None: # empty file
|
|
63
|
+
return {}
|
|
64
|
+
if not isinstance(raw, Mapping):
|
|
65
|
+
raise ConfigError(f"{path} must contain a YAML mapping at the root")
|
|
66
|
+
return cast("Mapping[str, Any]", raw)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _merge_into(dst: dict[str, Any], src: Mapping[str, Any]) -> None:
|
|
70
|
+
"""Recursive dict merge: nested mappings are merged key-by-key, scalars
|
|
71
|
+
and lists in ``src`` replace whatever ``dst`` holds.
|
|
72
|
+
"""
|
|
73
|
+
for key, value in src.items():
|
|
74
|
+
existing = dst.get(key)
|
|
75
|
+
if isinstance(existing, dict) and isinstance(value, Mapping):
|
|
76
|
+
_merge_into(existing, value)
|
|
77
|
+
else:
|
|
78
|
+
dst[key] = value
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def dump_config(config: Config) -> str:
|
|
82
|
+
"""Render a Config back into YAML for ``codemap config show``."""
|
|
83
|
+
return yaml.safe_dump(
|
|
84
|
+
config.model_dump(mode="json"),
|
|
85
|
+
sort_keys=False,
|
|
86
|
+
default_flow_style=False,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
__all__ = [
|
|
91
|
+
"ConfigError",
|
|
92
|
+
"dump_config",
|
|
93
|
+
"load_config",
|
|
94
|
+
"project_config_path",
|
|
95
|
+
"user_config_path",
|
|
96
|
+
]
|