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.
Files changed (52) hide show
  1. codemap/__init__.py +7 -0
  2. codemap/cli/__init__.py +3 -0
  3. codemap/cli/_common.py +90 -0
  4. codemap/cli/commands/__init__.py +3 -0
  5. codemap/cli/commands/callees.py +102 -0
  6. codemap/cli/commands/callers.py +107 -0
  7. codemap/cli/commands/config.py +78 -0
  8. codemap/cli/commands/diagnostics.py +142 -0
  9. codemap/cli/commands/doctor.py +158 -0
  10. codemap/cli/commands/get.py +93 -0
  11. codemap/cli/commands/index.py +725 -0
  12. codemap/cli/commands/routes.py +104 -0
  13. codemap/cli/commands/search.py +78 -0
  14. codemap/cli/commands/trace.py +179 -0
  15. codemap/cli/main.py +140 -0
  16. codemap/cli/renderers/__init__.py +3 -0
  17. codemap/cli/renderers/json.py +32 -0
  18. codemap/cli/renderers/text.py +24 -0
  19. codemap/config/__init__.py +31 -0
  20. codemap/config/loader.py +96 -0
  21. codemap/config/schema.py +122 -0
  22. codemap/core/__init__.py +7 -0
  23. codemap/core/bridge/__init__.py +8 -0
  24. codemap/core/bridge/base.py +38 -0
  25. codemap/core/bridge/http_route.py +374 -0
  26. codemap/core/bridge/python_cross_module.py +120 -0
  27. codemap/core/bridge/registry.py +117 -0
  28. codemap/core/graph.py +183 -0
  29. codemap/core/models.py +299 -0
  30. codemap/core/store.py +78 -0
  31. codemap/core/symbol.py +314 -0
  32. codemap/diagnostics/__init__.py +3 -0
  33. codemap/diagnostics/exit_codes.py +30 -0
  34. codemap/diagnostics/logging.py +65 -0
  35. codemap/diagnostics/progress.py +68 -0
  36. codemap/indexers/__init__.py +9 -0
  37. codemap/indexers/_example_lang.py +135 -0
  38. codemap/indexers/base.py +77 -0
  39. codemap/indexers/python.py +577 -0
  40. codemap/indexers/registry.py +104 -0
  41. codemap/io/__init__.py +8 -0
  42. codemap/io/atomic.py +97 -0
  43. codemap/io/base.py +12 -0
  44. codemap/io/json_store.py +433 -0
  45. codemap/io/lock.py +87 -0
  46. codemap/io/manifest.py +90 -0
  47. codemap/mcp/__init__.py +3 -0
  48. codemap_core-0.1.0.dist-info/METADATA +480 -0
  49. codemap_core-0.1.0.dist-info/RECORD +52 -0
  50. codemap_core-0.1.0.dist-info/WHEEL +4 -0
  51. codemap_core-0.1.0.dist-info/entry_points.txt +10 -0
  52. 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,3 @@
1
+ """Output renderers (JSON / rich text)."""
2
+
3
+ from __future__ import annotations
@@ -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
+ ]
@@ -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
+ ]