archgraph-mcp 1.0.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.
@@ -0,0 +1,3 @@
1
+ """ArchGraph MCP — codebase knowledge graph server."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,144 @@
1
+ """CLI entry-point for ArchGraph MCP.
2
+
3
+ Usage:
4
+ python -m archgraph_mcp analyze ./my_repo
5
+ python -m archgraph_mcp serve ./my_repo
6
+ python -m archgraph_mcp serve ./my_repo --transport sse --port 3847
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import logging
14
+ import os
15
+ from pathlib import Path
16
+ from typing import Literal, cast
17
+
18
+ from archgraph_mcp.graph.builder import GraphBuilder
19
+ from archgraph_mcp.graph.query_engine import QueryEngine
20
+ from archgraph_mcp.logging_config import setup_logging
21
+ from archgraph_mcp.storage.kuzu_store import KuzuStore
22
+
23
+
24
+ def _default_store_path() -> str:
25
+ return os.environ.get("ARCHGRAPH_STORE") or "archgraph.kuzu"
26
+
27
+
28
+ def main(argv: list[str] | None = None) -> None:
29
+ setup_logging()
30
+ parser = argparse.ArgumentParser(
31
+ prog="archgraph-mcp",
32
+ description="Build and query a knowledge graph of your codebase.",
33
+ )
34
+ sub = parser.add_subparsers(dest="command", required=True)
35
+
36
+ # ---- analyze ----
37
+ p_analyze = sub.add_parser("analyze", help="Analyze a repository and print summary")
38
+ p_analyze.add_argument(
39
+ "repo", type=Path, nargs="?", default=Path(os.environ.get("REPO_PATH", ".")), help="Path to the repository root"
40
+ )
41
+ p_analyze.add_argument(
42
+ "--store",
43
+ type=str,
44
+ default=_default_store_path(),
45
+ help="Kuzu database path (default: archgraph.kuzu or ARCHGRAPH_STORE)",
46
+ )
47
+ p_analyze.add_argument(
48
+ "--semantic-index",
49
+ action="store_true",
50
+ help="Build embedding vector index (requires pip install archgraph-mcp[semantic])",
51
+ )
52
+
53
+ # ---- serve ----
54
+ p_serve = sub.add_parser("serve", help="Start the MCP server")
55
+ p_serve.add_argument(
56
+ "repo", type=Path, nargs="?", default=Path(os.environ.get("REPO_PATH", ".")), help="Path to the repository root"
57
+ )
58
+ p_serve.add_argument(
59
+ "--store",
60
+ type=str,
61
+ default=_default_store_path(),
62
+ help="Kuzu database path (default: archgraph.kuzu or ARCHGRAPH_STORE)",
63
+ )
64
+ p_serve.add_argument(
65
+ "--transport",
66
+ type=str,
67
+ default=os.environ.get("MCP_TRANSPORT", "stdio"),
68
+ choices=["stdio", "sse", "streamable-http"],
69
+ help="MCP transport: stdio (local), sse, or streamable-http (remote)",
70
+ )
71
+ p_serve.add_argument(
72
+ "--port",
73
+ type=int,
74
+ default=int(os.environ.get("PORT", "3847")),
75
+ help="Port for HTTP transports (default 3847, or PORT env)",
76
+ )
77
+ p_serve.add_argument(
78
+ "--graph-ui",
79
+ action="store_true",
80
+ help="Expose GET /graph and /api/graph (HTTP transports only; not stdio)",
81
+ )
82
+
83
+ args = parser.parse_args(argv)
84
+
85
+ if args.command == "analyze":
86
+ _run_analyze(args.repo, args.store, semantic_index=args.semantic_index)
87
+ elif args.command == "serve":
88
+ graph_ui_flag = args.graph_ui or os.environ.get("GRAPH_UI", "").strip().lower() in (
89
+ "1",
90
+ "true",
91
+ "yes",
92
+ )
93
+ _run_serve(args.repo, args.store, args.transport, args.port, graph_ui=graph_ui_flag)
94
+
95
+
96
+ def _run_analyze(repo: Path, store_path: str, *, semantic_index: bool = False) -> None:
97
+ if semantic_index:
98
+ os.environ["ARCHGRAPH_BUILD_SEMANTIC_INDEX"] = "1"
99
+
100
+ builder = GraphBuilder()
101
+ builder.build_from_repository(repo.resolve())
102
+
103
+ resolved = str(Path(store_path).resolve())
104
+ store = KuzuStore(resolved)
105
+ store.save_graph(builder.all_nodes(), builder.all_edges())
106
+
107
+ from archgraph_mcp.semantic.build import maybe_build_semantic_index
108
+
109
+ maybe_build_semantic_index(resolved, builder.all_nodes())
110
+
111
+ engine = QueryEngine(builder.graph, builder._node_index, fts_store=store)
112
+ summary = engine.architecture_summary()
113
+ store.close()
114
+ print(json.dumps(summary.model_dump(), indent=2))
115
+
116
+
117
+ def _run_serve(
118
+ repo: Path,
119
+ store_path: str,
120
+ transport: str,
121
+ port: int,
122
+ *,
123
+ graph_ui: bool = False,
124
+ ) -> None:
125
+ # FastMCP binds ``self.settings.port`` from values captured when ``mcp`` is constructed
126
+ # (import time). Set these *before* importing ``mcp_server`` so ``--port`` is honored.
127
+ os.environ["PORT"] = str(port)
128
+ os.environ["FASTMCP_PORT"] = str(port)
129
+
130
+ from archgraph_mcp.server.mcp_server import initialize, mcp
131
+
132
+ log = logging.getLogger("archgraph_mcp")
133
+ graph_ui_effective = graph_ui and transport != "stdio"
134
+ if graph_ui and transport == "stdio":
135
+ log.warning(
136
+ "--graph-ui is ignored for stdio transport (no HTTP server). "
137
+ "Use --transport sse or streamable-http to enable /graph.",
138
+ )
139
+
140
+ # Build or load the graph
141
+ initialize(str(repo), store_path, graph_ui=graph_ui_effective)
142
+
143
+ # Let FastMCP handle server startup
144
+ mcp.run(transport=cast(Literal["stdio", "sse", "streamable-http"], transport))
archgraph_mcp/enums.py ADDED
@@ -0,0 +1,27 @@
1
+ """Deterministic enums for graph node and edge types."""
2
+
3
+ from enum import StrEnum
4
+
5
+
6
+ class NodeType(StrEnum):
7
+ """Types of nodes in the code knowledge graph."""
8
+
9
+ REPOSITORY = "repository"
10
+ MODULE = "module"
11
+ FILE = "file"
12
+ CLASS = "class"
13
+ FUNCTION = "function"
14
+ API = "api"
15
+ DATABASE = "database"
16
+
17
+
18
+ class EdgeType(StrEnum):
19
+ """Types of edges (relationships) in the code knowledge graph."""
20
+
21
+ IMPORTS = "imports"
22
+ CALLS = "calls"
23
+ DEFINES = "defines"
24
+ READS_TABLE = "reads_table"
25
+ WRITES_TABLE = "writes_table"
26
+ EXPOSES_API = "exposes_api"
27
+ DEPENDS_ON = "depends_on"
File without changes
@@ -0,0 +1,142 @@
1
+ """Graph builder — converts parser output into a NetworkX directed graph."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import time
7
+ from pathlib import Path
8
+
9
+ import networkx as nx
10
+
11
+ from archgraph_mcp.enums import EdgeType, NodeType
12
+ from archgraph_mcp.models import Edge, Node
13
+ from archgraph_mcp.parser.base import BaseParser, ParseResult
14
+ from archgraph_mcp.parser.java import JavaParser
15
+ from archgraph_mcp.parser.kotlin import KotlinParser
16
+ from archgraph_mcp.parser.typescript import TypeScriptParser
17
+ from archgraph_mcp.utils.scanner import (
18
+ detect_language,
19
+ scan_repository,
20
+ )
21
+
22
+ logger = logging.getLogger("archgraph_mcp.graph.builder")
23
+
24
+ # Registry: language string → parser instance
25
+ _PARSERS: dict[str, BaseParser] = {
26
+ "typescript": TypeScriptParser(),
27
+ "java": JavaParser(),
28
+ "kotlin": KotlinParser(),
29
+ }
30
+
31
+
32
+ class GraphBuilder:
33
+ """Incrementally builds a NetworkX DiGraph from a repository."""
34
+
35
+ def __init__(self) -> None:
36
+ self.graph: nx.DiGraph = nx.DiGraph()
37
+ self._node_index: dict[str, Node] = {}
38
+
39
+ # ------------------------------------------------------------------
40
+ # Public API
41
+ # ------------------------------------------------------------------
42
+
43
+ def build_from_repository(self, repo_path: Path) -> nx.DiGraph:
44
+ """Scan, parse, and build the graph for an entire repository."""
45
+ start = time.perf_counter()
46
+
47
+ # Repository root node
48
+ repo_id = f"repository:{repo_path.name}"
49
+ self._add_node(
50
+ Node(
51
+ id=repo_id,
52
+ type=NodeType.REPOSITORY,
53
+ name=repo_path.name,
54
+ )
55
+ )
56
+
57
+ for source_file in scan_repository(repo_path):
58
+ self._process_file(source_file, repo_id)
59
+
60
+ elapsed = time.perf_counter() - start
61
+ logger.info(
62
+ "Graph built: %d nodes, %d edges in %.2fs",
63
+ self.graph.number_of_nodes(),
64
+ self.graph.number_of_edges(),
65
+ elapsed,
66
+ )
67
+ return self.graph
68
+
69
+ def add_parse_result(self, result: ParseResult) -> None:
70
+ """Merge a ``ParseResult`` into the graph (for incremental use)."""
71
+ for node in result.nodes:
72
+ self._add_node(node)
73
+ for edge in result.edges:
74
+ self._add_edge(edge)
75
+
76
+ # ------------------------------------------------------------------
77
+ # Accessors
78
+ # ------------------------------------------------------------------
79
+
80
+ def get_node(self, node_id: str) -> Node | None:
81
+ """Return the parsed ``Node`` for *node_id*, or ``None`` if unknown."""
82
+ return self._node_index.get(node_id)
83
+
84
+ def all_nodes(self) -> list[Node]:
85
+ """Return every node currently in the index (order not guaranteed)."""
86
+ return list(self._node_index.values())
87
+
88
+ def all_edges(self) -> list[Edge]:
89
+ """Return all edges in the graph, with types taken from edge attributes."""
90
+ edges: list[Edge] = []
91
+ for u, v, data in self.graph.edges(data=True):
92
+ edges.append(Edge(source=u, target=v, type=data.get("type", EdgeType.DEPENDS_ON)))
93
+ return edges
94
+
95
+ # ------------------------------------------------------------------
96
+ # Internals
97
+ # ------------------------------------------------------------------
98
+
99
+ def _process_file(self, path: Path, repo_id: str) -> None:
100
+ """Parse *path* if supported and wire its file node to *repo_id*."""
101
+ language = detect_language(path)
102
+ if language is None:
103
+ return
104
+
105
+ parser = _PARSERS.get(language)
106
+ if parser is None:
107
+ logger.debug("No parser for language %s, skipping %s", language, path)
108
+ return
109
+
110
+ try:
111
+ source = path.read_bytes()
112
+ except (OSError, PermissionError):
113
+ logger.warning("Cannot read file: %s", path)
114
+ return
115
+
116
+ result = parser.parse_file(path, source)
117
+ self.add_parse_result(result)
118
+
119
+ # Link file node to repository
120
+ for node in result.nodes:
121
+ if node.type == NodeType.FILE:
122
+ self._add_edge(
123
+ Edge(
124
+ source=repo_id,
125
+ target=node.id,
126
+ type=EdgeType.DEPENDS_ON,
127
+ )
128
+ )
129
+
130
+ def _add_node(self, node: Node) -> None:
131
+ """Insert *node* into the index and NetworkX graph (first write wins)."""
132
+ if node.id not in self._node_index:
133
+ self._node_index[node.id] = node
134
+ self.graph.add_node(node.id, **node.model_dump())
135
+
136
+ def _add_edge(self, edge: Edge) -> None:
137
+ """Add *edge*; create stub endpoints if missing so NetworkX stays consistent."""
138
+ # Ensure both endpoints exist as graph nodes (at minimum as stubs)
139
+ for nid in (edge.source, edge.target):
140
+ if nid not in self.graph:
141
+ self.graph.add_node(nid)
142
+ self.graph.add_edge(edge.source, edge.target, type=edge.type)
@@ -0,0 +1,211 @@
1
+ """Query engine — graph traversal algorithms on the code knowledge graph."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import time
7
+ from collections import deque
8
+ from typing import Protocol
9
+
10
+ import networkx as nx
11
+
12
+ from archgraph_mcp.enums import EdgeType, NodeType
13
+ from archgraph_mcp.models import (
14
+ ArchitectureSummary,
15
+ Edge,
16
+ GraphQuery,
17
+ ImpactResult,
18
+ Node,
19
+ )
20
+
21
+ logger = logging.getLogger("archgraph_mcp.graph.query_engine")
22
+
23
+
24
+ class _SupportsFtsSearch(Protocol):
25
+ def search_nodes_fts(
26
+ self,
27
+ query: str,
28
+ node_type: NodeType | None,
29
+ limit: int,
30
+ node_by_id: dict[str, Node],
31
+ ) -> list[Node]: ...
32
+
33
+
34
+ class QueryEngine:
35
+ """Executes queries against a built NetworkX graph."""
36
+
37
+ def __init__(
38
+ self,
39
+ graph: nx.DiGraph,
40
+ node_index: dict[str, Node] | None = None,
41
+ *,
42
+ fts_store: _SupportsFtsSearch | None = None,
43
+ ) -> None:
44
+ self._g = graph
45
+ self._nodes = node_index or {}
46
+ self._fts_store = fts_store
47
+
48
+ @property
49
+ def node_index(self) -> dict[str, Node]:
50
+ """Map of node id → :class:`Node` (for tools that need lookup after search)."""
51
+ return self._nodes
52
+
53
+ # ------------------------------------------------------------------
54
+ # MCP tool: search_nodes
55
+ # ------------------------------------------------------------------
56
+
57
+ def search_nodes(
58
+ self,
59
+ query: str,
60
+ node_type: NodeType | None = None,
61
+ limit: int = 50,
62
+ ) -> list[Node]:
63
+ """FTS-ranked search when a store is configured; else substring on name/id."""
64
+ start = time.perf_counter()
65
+ if self._fts_store is not None:
66
+ fts_matches = self._fts_store.search_nodes_fts(query, node_type, limit, self._nodes)
67
+ if fts_matches:
68
+ elapsed = time.perf_counter() - start
69
+ logger.info("search_nodes(%r) fts → %d results in %.3fs", query, len(fts_matches), elapsed)
70
+ return fts_matches
71
+
72
+ query_lower = query.lower()
73
+ matches: list[Node] = []
74
+ for node in self._nodes.values():
75
+ if node_type and node.type != node_type:
76
+ continue
77
+ if query_lower in node.name.lower() or query_lower in node.id.lower():
78
+ matches.append(node)
79
+ if len(matches) >= limit:
80
+ break
81
+ logger.info("search_nodes(%r) → %d results in %.3fs", query, len(matches), time.perf_counter() - start)
82
+ return matches
83
+
84
+ # ------------------------------------------------------------------
85
+ # MCP tool: trace_dependencies (downstream)
86
+ # ------------------------------------------------------------------
87
+
88
+ def trace_dependencies(self, gq: GraphQuery) -> list[Node]:
89
+ """BFS downstream — what does *node_id* depend on?"""
90
+ return self._bfs(gq.node_id, gq.max_depth, successors=True)
91
+
92
+ # ------------------------------------------------------------------
93
+ # MCP tool: trace_dependents (upstream)
94
+ # ------------------------------------------------------------------
95
+
96
+ def trace_dependents(self, gq: GraphQuery) -> list[Node]:
97
+ """BFS upstream — what depends on *node_id*?"""
98
+ return self._bfs(gq.node_id, gq.max_depth, successors=False)
99
+
100
+ # ------------------------------------------------------------------
101
+ # MCP tool: impact_analysis
102
+ # ------------------------------------------------------------------
103
+
104
+ def impact_analysis(self, gq: GraphQuery) -> ImpactResult:
105
+ """BFS upstream to determine everything affected by a change to *node_id*."""
106
+ start = time.perf_counter()
107
+ affected = self._bfs(gq.node_id, gq.max_depth, successors=False)
108
+ affected_edges: list[Edge] = []
109
+
110
+ affected_ids = {n.id for n in affected} | {gq.node_id}
111
+ for u, v, data in self._g.edges(data=True):
112
+ if u in affected_ids and v in affected_ids:
113
+ affected_edges.append(
114
+ Edge(
115
+ source=u,
116
+ target=v,
117
+ type=data.get("type", EdgeType.DEPENDS_ON),
118
+ )
119
+ )
120
+
121
+ logger.info("impact_analysis(%s) → %d nodes in %.3fs", gq.node_id, len(affected), time.perf_counter() - start)
122
+ return ImpactResult(
123
+ source_node=gq.node_id,
124
+ affected_nodes=affected,
125
+ affected_edges=affected_edges,
126
+ depth=gq.max_depth,
127
+ )
128
+
129
+ # ------------------------------------------------------------------
130
+ # MCP tool: trace_path
131
+ # ------------------------------------------------------------------
132
+
133
+ def trace_path(self, source_id: str, target_id: str) -> list[Node]:
134
+ """Shortest path between *source_id* and *target_id* (undirected graph).
135
+
136
+ Only nodes present in the internal index are included; intermediate
137
+ graph ids without metadata are skipped.
138
+ """
139
+ try:
140
+ path_ids = nx.shortest_path(self._g.to_undirected(), source_id, target_id)
141
+ except (nx.NetworkXNoPath, nx.NodeNotFound):
142
+ return []
143
+ return [self._nodes[nid] for nid in path_ids if nid in self._nodes]
144
+
145
+ # ------------------------------------------------------------------
146
+ # MCP tool: architecture_summary
147
+ # ------------------------------------------------------------------
148
+
149
+ def architecture_summary(self) -> ArchitectureSummary:
150
+ """Return a compact high-level summary of the graph."""
151
+ node_counts: dict[str, int] = {}
152
+ languages: set[str] = set()
153
+ files_analyzed = 0
154
+
155
+ for node in self._nodes.values():
156
+ key = node.type.value
157
+ node_counts[key] = node_counts.get(key, 0) + 1
158
+ if node.type == NodeType.FILE:
159
+ files_analyzed += 1
160
+ if node.language:
161
+ languages.add(node.language)
162
+
163
+ edge_counts: dict[str, int] = {}
164
+ for _, _, data in self._g.edges(data=True):
165
+ etype = data.get("type", EdgeType.DEPENDS_ON)
166
+ key = etype.value if isinstance(etype, EdgeType) else str(etype)
167
+ edge_counts[key] = edge_counts.get(key, 0) + 1
168
+
169
+ return ArchitectureSummary(
170
+ total_nodes=self._g.number_of_nodes(),
171
+ total_edges=self._g.number_of_edges(),
172
+ node_counts=node_counts,
173
+ edge_counts=edge_counts,
174
+ files_analyzed=files_analyzed,
175
+ languages=sorted(languages),
176
+ )
177
+
178
+ # ------------------------------------------------------------------
179
+ # Internal helpers
180
+ # ------------------------------------------------------------------
181
+
182
+ def _bfs(
183
+ self,
184
+ start: str,
185
+ max_depth: int,
186
+ *,
187
+ successors: bool,
188
+ ) -> list[Node]:
189
+ """Generic BFS. *successors=True* traverses downstream, else upstream."""
190
+ if start not in self._g:
191
+ return []
192
+
193
+ visited: set[str] = set()
194
+ queue: deque[tuple[str, int]] = deque([(start, 0)])
195
+ result: list[Node] = []
196
+
197
+ while queue:
198
+ current, depth = queue.popleft()
199
+ if current in visited or depth > max_depth:
200
+ continue
201
+ visited.add(current)
202
+
203
+ if current != start and current in self._nodes:
204
+ result.append(self._nodes[current])
205
+
206
+ neighbors = self._g.successors(current) if successors else self._g.predecessors(current)
207
+ for neighbor in neighbors:
208
+ if neighbor not in visited:
209
+ queue.append((neighbor, depth + 1))
210
+
211
+ return result
@@ -0,0 +1,27 @@
1
+ """Structured logging configuration for ArchGraph MCP."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import sys
7
+
8
+
9
+ def setup_logging(level: int = logging.INFO) -> logging.Logger:
10
+ """Configure and return the root logger for archgraph_mcp."""
11
+ logger = logging.getLogger("archgraph_mcp")
12
+ if logger.handlers:
13
+ return logger
14
+
15
+ logger.setLevel(level)
16
+
17
+ handler = logging.StreamHandler(sys.stderr)
18
+ handler.setLevel(level)
19
+
20
+ formatter = logging.Formatter(
21
+ fmt="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
22
+ datefmt="%Y-%m-%d %H:%M:%S",
23
+ )
24
+ handler.setFormatter(formatter)
25
+ logger.addHandler(handler)
26
+
27
+ return logger
@@ -0,0 +1,56 @@
1
+ """Pydantic data models for structured serialization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+ from archgraph_mcp.enums import EdgeType, NodeType
10
+
11
+
12
+ class Node(BaseModel):
13
+ """A single node in the code knowledge graph."""
14
+
15
+ id: str = Field(..., description="Unique identifier, e.g. 'function:auth.loginUser'")
16
+ type: NodeType
17
+ name: str
18
+ file: str | None = None
19
+ language: str | None = None
20
+ metadata: dict[str, Any] = Field(default_factory=dict)
21
+
22
+
23
+ class Edge(BaseModel):
24
+ """A directed relationship between two nodes."""
25
+
26
+ source: str = Field(..., description="Source node id")
27
+ target: str = Field(..., description="Target node id")
28
+ type: EdgeType
29
+
30
+
31
+ class GraphQuery(BaseModel):
32
+ """Parameters for a graph query."""
33
+
34
+ node_id: str
35
+ max_depth: int = Field(default=10, ge=1, le=100)
36
+ edge_types: list[EdgeType] | None = None
37
+
38
+
39
+ class ImpactResult(BaseModel):
40
+ """Result of an impact analysis query."""
41
+
42
+ source_node: str
43
+ affected_nodes: list[Node] = Field(default_factory=list)
44
+ affected_edges: list[Edge] = Field(default_factory=list)
45
+ depth: int = 0
46
+
47
+
48
+ class ArchitectureSummary(BaseModel):
49
+ """High-level architecture summary of the graph."""
50
+
51
+ total_nodes: int = 0
52
+ total_edges: int = 0
53
+ node_counts: dict[str, int] = Field(default_factory=dict)
54
+ edge_counts: dict[str, int] = Field(default_factory=dict)
55
+ files_analyzed: int = 0
56
+ languages: list[str] = Field(default_factory=list)
File without changes
@@ -0,0 +1,61 @@
1
+ """Abstract base parser interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from abc import ABC, abstractmethod
7
+ from pathlib import Path
8
+
9
+ from tree_sitter import Node as TSNode
10
+
11
+ from archgraph_mcp.models import Edge, Node
12
+
13
+ logger = logging.getLogger("archgraph_mcp.parser")
14
+
15
+
16
+ def utf8_node_text(node: TSNode) -> str:
17
+ """Return UTF-8 text for a tree-sitter node, or empty string if absent."""
18
+ raw = node.text
19
+ if raw is None:
20
+ return ""
21
+ return raw.decode("utf-8")
22
+
23
+
24
+ class ParseResult:
25
+ """Container for nodes and edges extracted from a single file."""
26
+
27
+ __slots__ = ("nodes", "edges")
28
+
29
+ def __init__(self) -> None:
30
+ """Create empty node and edge lists."""
31
+ self.nodes: list[Node] = []
32
+ self.edges: list[Edge] = []
33
+
34
+ def add_node(self, node: Node) -> None:
35
+ """Append *node* to the result list."""
36
+ self.nodes.append(node)
37
+
38
+ def add_edge(self, edge: Edge) -> None:
39
+ """Append *edge* to the result list."""
40
+ self.edges.append(edge)
41
+
42
+
43
+ class BaseParser(ABC):
44
+ """Interface every language parser must implement."""
45
+
46
+ language: str # e.g. "typescript"
47
+
48
+ @abstractmethod
49
+ def parse_file(self, path: Path, source: bytes) -> ParseResult:
50
+ """Parse *source* and return extracted nodes and edges.
51
+
52
+ Implementations must catch internal errors and return a
53
+ partial ``ParseResult`` rather than raising.
54
+ """
55
+
56
+ # ------- helpers available to subclasses ------
57
+
58
+ @staticmethod
59
+ def _make_id(kind: str, *parts: str) -> str:
60
+ """Build a deterministic node id, e.g. ``function:auth.login``."""
61
+ return f"{kind}:{'.'.join(parts)}"