ctxgraph-code 0.1.2__tar.gz → 0.2.0__tar.gz

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 (35) hide show
  1. {ctxgraph_code-0.1.2 → ctxgraph_code-0.2.0}/PKG-INFO +10 -6
  2. {ctxgraph_code-0.1.2 → ctxgraph_code-0.2.0}/README.md +9 -5
  3. {ctxgraph_code-0.1.2 → ctxgraph_code-0.2.0}/pyproject.toml +1 -1
  4. {ctxgraph_code-0.1.2 → ctxgraph_code-0.2.0}/src/ctxgraph_code/analyzers/python/importer.py +76 -57
  5. {ctxgraph_code-0.1.2 → ctxgraph_code-0.2.0}/src/ctxgraph_code/analyzers/python/semantic.py +14 -6
  6. ctxgraph_code-0.2.0/src/ctxgraph_code/analyzers/python/symbols.py +227 -0
  7. ctxgraph_code-0.2.0/src/ctxgraph_code/cli.py +692 -0
  8. ctxgraph_code-0.2.0/src/ctxgraph_code/config/global_paths.py +16 -0
  9. {ctxgraph_code-0.1.2 → ctxgraph_code-0.2.0}/src/ctxgraph_code/config/settings.py +9 -5
  10. {ctxgraph_code-0.1.2 → ctxgraph_code-0.2.0}/src/ctxgraph_code/exclude/patterns.py +2 -0
  11. ctxgraph_code-0.2.0/src/ctxgraph_code/graph/builder.py +204 -0
  12. {ctxgraph_code-0.1.2 → ctxgraph_code-0.2.0}/src/ctxgraph_code/graph/models.py +33 -10
  13. ctxgraph_code-0.2.0/src/ctxgraph_code/view/__init__.py +0 -0
  14. ctxgraph_code-0.2.0/src/ctxgraph_code/view/visualizer.py +288 -0
  15. {ctxgraph_code-0.1.2 → ctxgraph_code-0.2.0}/src/ctxgraph_code.egg-info/PKG-INFO +10 -6
  16. {ctxgraph_code-0.1.2 → ctxgraph_code-0.2.0}/src/ctxgraph_code.egg-info/SOURCES.txt +4 -1
  17. ctxgraph_code-0.1.2/src/ctxgraph_code/analyzers/python/symbols.py +0 -221
  18. ctxgraph_code-0.1.2/src/ctxgraph_code/cli.py +0 -431
  19. ctxgraph_code-0.1.2/src/ctxgraph_code/graph/builder.py +0 -78
  20. {ctxgraph_code-0.1.2 → ctxgraph_code-0.2.0}/setup.cfg +0 -0
  21. {ctxgraph_code-0.1.2 → ctxgraph_code-0.2.0}/src/ctxgraph_code/__init__.py +0 -0
  22. {ctxgraph_code-0.1.2 → ctxgraph_code-0.2.0}/src/ctxgraph_code/__main__.py +0 -0
  23. {ctxgraph_code-0.1.2 → ctxgraph_code-0.2.0}/src/ctxgraph_code/analyzers/__init__.py +0 -0
  24. {ctxgraph_code-0.1.2 → ctxgraph_code-0.2.0}/src/ctxgraph_code/analyzers/python/__init__.py +0 -0
  25. {ctxgraph_code-0.1.2 → ctxgraph_code-0.2.0}/src/ctxgraph_code/config/__init__.py +0 -0
  26. {ctxgraph_code-0.1.2 → ctxgraph_code-0.2.0}/src/ctxgraph_code/config/init.py +0 -0
  27. {ctxgraph_code-0.1.2 → ctxgraph_code-0.2.0}/src/ctxgraph_code/exclude/__init__.py +0 -0
  28. {ctxgraph_code-0.1.2 → ctxgraph_code-0.2.0}/src/ctxgraph_code/graph/__init__.py +0 -0
  29. {ctxgraph_code-0.1.2 → ctxgraph_code-0.2.0}/src/ctxgraph_code/graph/query.py +0 -0
  30. {ctxgraph_code-0.1.2 → ctxgraph_code-0.2.0}/src/ctxgraph_code/graph/storage.py +0 -0
  31. {ctxgraph_code-0.1.2 → ctxgraph_code-0.2.0}/src/ctxgraph_code/render.py +0 -0
  32. {ctxgraph_code-0.1.2 → ctxgraph_code-0.2.0}/src/ctxgraph_code.egg-info/dependency_links.txt +0 -0
  33. {ctxgraph_code-0.1.2 → ctxgraph_code-0.2.0}/src/ctxgraph_code.egg-info/entry_points.txt +0 -0
  34. {ctxgraph_code-0.1.2 → ctxgraph_code-0.2.0}/src/ctxgraph_code.egg-info/requires.txt +0 -0
  35. {ctxgraph_code-0.1.2 → ctxgraph_code-0.2.0}/src/ctxgraph_code.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ctxgraph-code
3
- Version: 0.1.2
3
+ Version: 0.2.0
4
4
  Summary: Code knowledge graph for Claude Code. Build a relationship graph of your Python codebase and query it during coding sessions.
5
5
  Author: ctxgraph-code contributors
6
6
  License: MIT
@@ -165,13 +165,17 @@ Generates a focused context summary: relevant files, their symbols, and dependen
165
165
  ### `view`
166
166
 
167
167
  ```bash
168
- ctxgraph-code view
169
- ctxgraph-code view --output tree.txt
168
+ ctxgraph-code view # generates interactive D3.js HTML and opens browser
169
+ ctxgraph-code view --no-open # generate HTML without opening browser
170
+ ctxgraph-code view --tree # show text tree instead (useful in terminal)
171
+ ctxgraph-code view --output graph.html # save to custom path
170
172
  ```
171
173
 
172
- Prints a hierarchical directory tree from the graph showing every file and its symbols (classes marked `[C]`, functions/methods marked `[M]`). Also lists import and call edges at the bottom.
174
+ Opens an **interactive D3.js force-directed graph** in the browser. Drag nodes, zoom/pan, search by name, filter by type (File/Class/Function). Hover to highlight connected nodes and see summaries.
173
175
 
174
- Useful for a quick visual scan of the project structure without reading every file.
176
+ The HTML is self-contained (loads D3.js from CDN) and saved to `.ctxgraph/graph.html`.
177
+
178
+ Use `--tree` for a terminal-friendly text view of the directory hierarchy with symbols and edges.
175
179
 
176
180
  ### `info`
177
181
 
@@ -286,7 +290,7 @@ Built-in default exclusion patterns (always applied): `__pycache__`, `*.pyc`, `.
286
290
  | CLI commands | 9 (build, capsule, query, view, serve, info, init, ask, chat, history, skill) | 9 (init, build, query, deps, usedby, overview, symbols, context, setup, view, info) |
287
291
  | LLM integration | Built-in (Ollama, Claude, OpenAI, Azure) | None (delegates to Claude Code) |
288
292
  | Chat sessions | Yes | No |
289
- | Visualizer | D3.js HTML + SVG | Text tree (`view` command) |
293
+ | Visualizer | D3.js HTML + SVG | D3.js HTML (`view` opens in browser, `--tree` for text) |
290
294
  | Skills system | Yes (customizable skill TOML files) | No |
291
295
  | MCP server | Yes | No |
292
296
  | Token savings | Yes (capsule DSL compression) | No |
@@ -142,13 +142,17 @@ Generates a focused context summary: relevant files, their symbols, and dependen
142
142
  ### `view`
143
143
 
144
144
  ```bash
145
- ctxgraph-code view
146
- ctxgraph-code view --output tree.txt
145
+ ctxgraph-code view # generates interactive D3.js HTML and opens browser
146
+ ctxgraph-code view --no-open # generate HTML without opening browser
147
+ ctxgraph-code view --tree # show text tree instead (useful in terminal)
148
+ ctxgraph-code view --output graph.html # save to custom path
147
149
  ```
148
150
 
149
- Prints a hierarchical directory tree from the graph showing every file and its symbols (classes marked `[C]`, functions/methods marked `[M]`). Also lists import and call edges at the bottom.
151
+ Opens an **interactive D3.js force-directed graph** in the browser. Drag nodes, zoom/pan, search by name, filter by type (File/Class/Function). Hover to highlight connected nodes and see summaries.
150
152
 
151
- Useful for a quick visual scan of the project structure without reading every file.
153
+ The HTML is self-contained (loads D3.js from CDN) and saved to `.ctxgraph/graph.html`.
154
+
155
+ Use `--tree` for a terminal-friendly text view of the directory hierarchy with symbols and edges.
152
156
 
153
157
  ### `info`
154
158
 
@@ -263,7 +267,7 @@ Built-in default exclusion patterns (always applied): `__pycache__`, `*.pyc`, `.
263
267
  | CLI commands | 9 (build, capsule, query, view, serve, info, init, ask, chat, history, skill) | 9 (init, build, query, deps, usedby, overview, symbols, context, setup, view, info) |
264
268
  | LLM integration | Built-in (Ollama, Claude, OpenAI, Azure) | None (delegates to Claude Code) |
265
269
  | Chat sessions | Yes | No |
266
- | Visualizer | D3.js HTML + SVG | Text tree (`view` command) |
270
+ | Visualizer | D3.js HTML + SVG | D3.js HTML (`view` opens in browser, `--tree` for text) |
267
271
  | Skills system | Yes (customizable skill TOML files) | No |
268
272
  | MCP server | Yes | No |
269
273
  | Token savings | Yes (capsule DSL compression) | No |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ctxgraph-code"
7
- version = "0.1.2"
7
+ version = "0.2.0"
8
8
  description = "Code knowledge graph for Claude Code. Build a relationship graph of your Python codebase and query it during coding sessions."
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -4,90 +4,99 @@ import ast
4
4
  from pathlib import Path
5
5
  from typing import Optional
6
6
 
7
- from ctxgraph_code.graph.models import Edge, Graph, Node
8
-
9
-
10
- def analyze_imports(file_path: Path, root_path: Path) -> Graph:
11
- graph = Graph()
7
+ from ctxgraph_code.graph.models import Graph
8
+
9
+
10
+ def analyze_imports(
11
+ file_path: Path,
12
+ root_path: Path,
13
+ *,
14
+ tree: Optional[ast.AST] = None,
15
+ path_index: Optional[set[str]] = None,
16
+ ) -> Graph:
17
+ # Result containers (not full Graph — caller merges)
18
+ nodes: list[dict] = []
19
+ edges: list[dict] = []
12
20
  rel_path = _relative_path(file_path, root_path)
13
21
  file_node_id = f"file:{rel_path}"
14
22
 
15
- graph.add_node(
16
- Node(
17
- id=file_node_id,
18
- type="file",
19
- name=file_path.name,
20
- path=rel_path,
21
- summary=None,
22
- importance=0.5,
23
- size_bytes=file_path.stat().st_size,
24
- )
25
- )
26
-
27
- try:
28
- tree = ast.parse(file_path.read_text(encoding="utf-8", errors="replace"))
29
- except SyntaxError:
30
- return graph
23
+ nodes.append(dict(
24
+ id=file_node_id,
25
+ type="file",
26
+ name=file_path.name,
27
+ path=rel_path,
28
+ summary=None,
29
+ importance=0.5,
30
+ size_bytes=file_path.stat().st_size,
31
+ ))
32
+
33
+ if tree is None:
34
+ try:
35
+ tree = ast.parse(file_path.read_text(encoding="utf-8", errors="replace"))
36
+ except SyntaxError:
37
+ return Graph.from_batch(nodes, edges)
31
38
 
32
39
  for node in ast.walk(tree):
33
40
  if isinstance(node, ast.Import):
34
41
  for alias in node.names:
35
- target = _resolve_import_target(alias.name, root_path)
42
+ target = _resolve_import_target(alias.name, root_path, path_index)
36
43
  if target:
37
- _add_import_edge(graph, rel_path, file_node_id, target, alias.name)
44
+ _add_import_edge(nodes, edges, rel_path, file_node_id, target, alias.name)
38
45
 
39
46
  elif isinstance(node, ast.ImportFrom):
40
47
  if node.module is None:
41
48
  continue
42
49
  if node.level and node.level > 0:
43
50
  target = _resolve_relative_import(
44
- node.module, node.level, rel_path, root_path
51
+ node.module, node.level, rel_path, root_path, path_index
45
52
  )
46
53
  else:
47
- target = _resolve_import_target(node.module, root_path)
54
+ target = _resolve_import_target(node.module, root_path, path_index)
48
55
 
49
56
  if target:
50
57
  for alias in node.names:
51
58
  symbol_name = alias.asname or alias.name
52
59
  edge_label = f"{node.module}.{alias.name}"
53
60
  _add_import_edge(
54
- graph, rel_path, file_node_id, target, edge_label
61
+ nodes, edges, rel_path, file_node_id, target, edge_label
55
62
  )
56
63
 
57
- return graph
64
+ return Graph.from_batch(nodes, edges)
58
65
 
59
66
 
60
67
  def _add_import_edge(
61
- graph: Graph,
68
+ nodes: list[dict],
69
+ edges: list[dict],
62
70
  source_rel: str,
63
71
  source_id: str,
64
72
  target_rel: str,
65
73
  label: str,
66
74
  ):
67
75
  target_id = f"file:{target_rel}"
68
- if target_id not in graph.nodes:
69
- graph.add_node(
70
- Node(
71
- id=target_id,
72
- type="file",
73
- name=Path(target_rel).name,
74
- path=target_rel,
75
- summary=None,
76
- importance=0.3,
77
- size_bytes=0,
78
- )
79
- )
80
- graph.add_edge(
81
- Edge(
82
- source_id=source_id,
83
- target_id=target_id,
84
- relation="imports",
85
- weight=1.0,
86
- )
87
- )
88
-
89
-
90
- def _resolve_import_target(module_name: str, root_path: Path) -> Optional[str]:
76
+ # Check if target node already in our batch list
77
+ if not any(n["id"] == target_id for n in nodes):
78
+ nodes.append(dict(
79
+ id=target_id,
80
+ type="file",
81
+ name=Path(target_rel).name,
82
+ path=target_rel,
83
+ summary=None,
84
+ importance=0.3,
85
+ size_bytes=0,
86
+ ))
87
+ edges.append(dict(
88
+ source_id=source_id,
89
+ target_id=target_id,
90
+ relation="imports",
91
+ weight=1.0,
92
+ ))
93
+
94
+
95
+ def _resolve_import_target(
96
+ module_name: str,
97
+ root_path: Path,
98
+ path_index: Optional[set[str]] = None,
99
+ ) -> Optional[str]:
91
100
  package_path = module_name.replace(".", "/")
92
101
  root_name = root_path.name
93
102
  extensions = [".py", ".pyw"]
@@ -100,16 +109,20 @@ def _resolve_import_target(module_name: str, root_path: Path) -> Optional[str]:
100
109
  for base in candidates:
101
110
  for ext in extensions:
102
111
  candidate = f"{base}{ext}"
103
- if _exists_in_project(candidate, root_path):
112
+ if _exists_in_project(candidate, root_path, path_index):
104
113
  return candidate
105
114
  init_candidate = f"{base}/__init__{ext}"
106
- if _exists_in_project(init_candidate, root_path):
115
+ if _exists_in_project(init_candidate, root_path, path_index):
107
116
  return init_candidate
108
117
  return None
109
118
 
110
119
 
111
120
  def _resolve_relative_import(
112
- module_name: str, level: int, source_rel: str, root_path: Path
121
+ module_name: str,
122
+ level: int,
123
+ source_rel: str,
124
+ root_path: Path,
125
+ path_index: Optional[set[str]] = None,
113
126
  ) -> Optional[str]:
114
127
  parts = Path(source_rel).parts
115
128
  if level > len(parts):
@@ -120,15 +133,21 @@ def _resolve_relative_import(
120
133
  extensions = [".py", ".pyw"]
121
134
  for ext in extensions:
122
135
  candidate = f"{base}{ext}"
123
- if _exists_in_project(candidate, root_path):
136
+ if _exists_in_project(candidate, root_path, path_index):
124
137
  return candidate
125
138
  init_candidate = f"{base}/__init__{ext}"
126
- if _exists_in_project(init_candidate, root_path):
139
+ if _exists_in_project(init_candidate, root_path, path_index):
127
140
  return init_candidate
128
141
  return None
129
142
 
130
143
 
131
- def _exists_in_project(candidate_path: str, root_path: Path) -> bool:
144
+ def _exists_in_project(
145
+ candidate_path: str,
146
+ root_path: Path,
147
+ path_index: Optional[set[str]] = None,
148
+ ) -> bool:
149
+ if path_index is not None:
150
+ return candidate_path in path_index
132
151
  full = root_path / candidate_path
133
152
  return full.exists()
134
153
 
@@ -2,19 +2,27 @@ from __future__ import annotations
2
2
 
3
3
  import ast
4
4
  from pathlib import Path
5
+ from typing import Optional
5
6
 
6
7
  from ctxgraph_code.graph.models import Node
7
8
 
8
9
 
9
- def enrich_node_summary(node: Node, file_path: Path) -> str:
10
+ def enrich_node_summary(
11
+ node: Node,
12
+ file_path: Path,
13
+ *,
14
+ source: Optional[str] = None,
15
+ tree: Optional[ast.AST] = None,
16
+ ) -> str:
10
17
  if node.summary:
11
18
  return node.summary
12
19
 
13
- try:
14
- source = file_path.read_text(encoding="utf-8", errors="replace")
15
- tree = ast.parse(source)
16
- except (SyntaxError, FileNotFoundError):
17
- return node.summary or ""
20
+ if tree is None:
21
+ try:
22
+ source = file_path.read_text(encoding="utf-8", errors="replace")
23
+ tree = ast.parse(source)
24
+ except (SyntaxError, FileNotFoundError):
25
+ return node.summary or ""
18
26
 
19
27
  if node.type == "file":
20
28
  return _summarize_file(tree, file_path)
@@ -0,0 +1,227 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ from ctxgraph_code.graph.models import Graph
8
+
9
+
10
+ def analyze_symbols(
11
+ file_path: Path,
12
+ root_path: Path,
13
+ *,
14
+ source: Optional[str] = None,
15
+ tree: Optional[ast.AST] = None,
16
+ ) -> Graph:
17
+ rel_path = _relative_path(file_path, root_path)
18
+ file_node_id = f"file:{rel_path}"
19
+
20
+ if tree is None:
21
+ try:
22
+ source = file_path.read_text(encoding="utf-8", errors="replace")
23
+ tree = ast.parse(source)
24
+ except SyntaxError:
25
+ return Graph()
26
+
27
+ lines = source.split("\n") if source else []
28
+ nodes: list[dict] = []
29
+ edges: list[dict] = []
30
+
31
+ for node in ast.iter_child_nodes(tree):
32
+ if isinstance(node, ast.ClassDef):
33
+ _process_class(node, nodes, edges, file_node_id, rel_path, lines)
34
+ elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
35
+ _process_function(node, nodes, edges, file_node_id, rel_path, lines)
36
+
37
+ _process_calls(tree, nodes, edges, rel_path)
38
+
39
+ return Graph.from_batch(nodes, edges)
40
+
41
+
42
+ # ── helpers ─────────────────────────────────────────────────────────────────
43
+
44
+
45
+ def _node_dict(**kw) -> dict:
46
+ return dict(kw)
47
+
48
+
49
+ def _edge_dict(**kw) -> dict:
50
+ return dict(kw)
51
+
52
+
53
+ def _process_class(
54
+ node: ast.ClassDef,
55
+ nodes: list[dict],
56
+ edges: list[dict],
57
+ file_node_id: str,
58
+ rel_path: str,
59
+ lines: list[str],
60
+ ):
61
+ class_id = f"class:{rel_path}::{node.name}"
62
+ summary = _extract_docstring(node)
63
+ bases = _get_base_names(node)
64
+
65
+ nodes.append(_node_dict(
66
+ id=class_id,
67
+ type="class",
68
+ name=node.name,
69
+ path=rel_path,
70
+ parent_id=file_node_id,
71
+ summary=summary,
72
+ importance=0.6,
73
+ lineno=node.lineno,
74
+ ))
75
+ edges.append(_edge_dict(
76
+ source_id=file_node_id,
77
+ target_id=class_id,
78
+ relation="defines",
79
+ weight=1.0,
80
+ ))
81
+
82
+ for base in bases:
83
+ edges.append(_edge_dict(
84
+ source_id=class_id,
85
+ target_id=base,
86
+ relation="extends",
87
+ weight=0.8,
88
+ ))
89
+
90
+ for child in ast.iter_child_nodes(node):
91
+ if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
92
+ _process_method(child, nodes, edges, class_id, rel_path, lines)
93
+
94
+
95
+ def _process_function(
96
+ node: (ast.FunctionDef | ast.AsyncFunctionDef),
97
+ nodes: list[dict],
98
+ edges: list[dict],
99
+ file_node_id: str,
100
+ rel_path: str,
101
+ lines: list[str],
102
+ ):
103
+ func_id = f"func:{rel_path}::{node.name}"
104
+ summary = _extract_docstring(node)
105
+
106
+ nodes.append(_node_dict(
107
+ id=func_id,
108
+ type="function",
109
+ name=node.name,
110
+ path=rel_path,
111
+ parent_id=file_node_id,
112
+ summary=summary,
113
+ importance=0.5,
114
+ lineno=node.lineno,
115
+ ))
116
+ edges.append(_edge_dict(
117
+ source_id=file_node_id,
118
+ target_id=func_id,
119
+ relation="defines",
120
+ weight=1.0,
121
+ ))
122
+
123
+
124
+ def _process_method(
125
+ node: (ast.FunctionDef | ast.AsyncFunctionDef),
126
+ nodes: list[dict],
127
+ edges: list[dict],
128
+ class_id: str,
129
+ rel_path: str,
130
+ lines: list[str],
131
+ ):
132
+ method_id = f"func:{rel_path}::{node.name}"
133
+ summary = _extract_docstring(node)
134
+
135
+ nodes.append(_node_dict(
136
+ id=method_id,
137
+ type="function",
138
+ name=node.name,
139
+ path=rel_path,
140
+ parent_id=class_id,
141
+ summary=summary,
142
+ importance=0.5,
143
+ lineno=node.lineno,
144
+ ))
145
+ edges.append(_edge_dict(
146
+ source_id=class_id,
147
+ target_id=method_id,
148
+ relation="defines",
149
+ weight=1.0,
150
+ ))
151
+
152
+
153
+ def _process_calls(tree: ast.AST, nodes: list[dict], edges: list[dict], rel_path: str):
154
+ for node in ast.walk(tree):
155
+ if isinstance(node, ast.Call):
156
+ func_name = _get_call_name(node.func)
157
+ if func_name:
158
+ caller_id = _find_enclosing_symbol(tree, node, rel_path)
159
+ if caller_id:
160
+ callee_id = _find_target_node(func_name, nodes)
161
+ if callee_id:
162
+ edges.append(_edge_dict(
163
+ source_id=caller_id,
164
+ target_id=callee_id,
165
+ relation="calls",
166
+ weight=0.7,
167
+ ))
168
+
169
+
170
+ def _get_call_name(node: ast.AST) -> Optional[str]:
171
+ if isinstance(node, ast.Name):
172
+ return node.id
173
+ elif isinstance(node, ast.Attribute):
174
+ return node.attr
175
+ return None
176
+
177
+
178
+ def _find_enclosing_symbol(
179
+ tree: ast.AST, target_node: ast.AST, rel_path: str
180
+ ) -> Optional[str]:
181
+ for node in ast.walk(tree):
182
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
183
+ if _contains_node(node, target_node):
184
+ return f"func:{rel_path}::{node.name}"
185
+ elif isinstance(node, ast.ClassDef):
186
+ for child in ast.iter_child_nodes(node):
187
+ if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
188
+ if _contains_node(child, target_node):
189
+ return f"func:{rel_path}::{child.name}"
190
+ return None
191
+
192
+
193
+ def _contains_node(container: ast.AST, target: ast.AST) -> bool:
194
+ for node in ast.walk(container):
195
+ if node is target:
196
+ return True
197
+ return False
198
+
199
+
200
+ def _find_target_node(name: str, nodes: list[dict]) -> Optional[str]:
201
+ for nd in nodes:
202
+ if nd.get("type") in ("function", "class") and nd["name"] == name:
203
+ return nd["id"]
204
+ return None
205
+
206
+
207
+ def _extract_docstring(node: ast.AST) -> Optional[str]:
208
+ if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)):
209
+ doc = ast.get_docstring(node)
210
+ if doc:
211
+ return doc.split("\n\n")[0][:200]
212
+ return None
213
+
214
+
215
+ def _get_base_names(node: ast.ClassDef) -> list[str]:
216
+ bases = []
217
+ for base in node.bases:
218
+ if isinstance(base, ast.Name):
219
+ bases.append(f"class:{base.id}")
220
+ return bases
221
+
222
+
223
+ def _relative_path(file_path: Path, root_path: Path) -> str:
224
+ try:
225
+ return str(file_path.relative_to(root_path)).replace("\\", "/")
226
+ except ValueError:
227
+ return file_path.name