codegraph-cli 2.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.
- codegraph_cli/__init__.py +4 -0
- codegraph_cli/agents.py +191 -0
- codegraph_cli/bug_detector.py +386 -0
- codegraph_cli/chat_agent.py +352 -0
- codegraph_cli/chat_session.py +220 -0
- codegraph_cli/cli.py +330 -0
- codegraph_cli/cli_chat.py +367 -0
- codegraph_cli/cli_diagnose.py +133 -0
- codegraph_cli/cli_refactor.py +230 -0
- codegraph_cli/cli_setup.py +470 -0
- codegraph_cli/cli_test.py +177 -0
- codegraph_cli/cli_v2.py +267 -0
- codegraph_cli/codegen_agent.py +265 -0
- codegraph_cli/config.py +31 -0
- codegraph_cli/config_manager.py +341 -0
- codegraph_cli/context_manager.py +500 -0
- codegraph_cli/crew_agents.py +123 -0
- codegraph_cli/crew_chat.py +159 -0
- codegraph_cli/crew_tools.py +497 -0
- codegraph_cli/diff_engine.py +265 -0
- codegraph_cli/embeddings.py +241 -0
- codegraph_cli/graph_export.py +144 -0
- codegraph_cli/llm.py +642 -0
- codegraph_cli/models.py +47 -0
- codegraph_cli/models_v2.py +185 -0
- codegraph_cli/orchestrator.py +49 -0
- codegraph_cli/parser.py +800 -0
- codegraph_cli/performance_analyzer.py +223 -0
- codegraph_cli/project_context.py +230 -0
- codegraph_cli/rag.py +200 -0
- codegraph_cli/refactor_agent.py +452 -0
- codegraph_cli/security_scanner.py +366 -0
- codegraph_cli/storage.py +390 -0
- codegraph_cli/templates/graph_interactive.html +257 -0
- codegraph_cli/testgen_agent.py +316 -0
- codegraph_cli/validation_engine.py +285 -0
- codegraph_cli/vector_store.py +293 -0
- codegraph_cli-2.0.0.dist-info/METADATA +318 -0
- codegraph_cli-2.0.0.dist-info/RECORD +43 -0
- codegraph_cli-2.0.0.dist-info/WHEEL +5 -0
- codegraph_cli-2.0.0.dist-info/entry_points.txt +2 -0
- codegraph_cli-2.0.0.dist-info/licenses/LICENSE +21 -0
- codegraph_cli-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
"""RefactorAgent for safe, dependency-aware code refactoring."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import uuid
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import List, Optional, Set
|
|
9
|
+
|
|
10
|
+
from .diff_engine import DiffEngine
|
|
11
|
+
from .models_v2 import FileChange, Location, Range, RefactorPlan
|
|
12
|
+
from .storage import GraphStore
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RefactorAgent:
|
|
16
|
+
"""Performs safe refactoring with automatic dependency tracking."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, store: GraphStore, diff_engine: Optional[DiffEngine] = None):
|
|
19
|
+
"""Initialize RefactorAgent.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
store: Graph store for dependency tracking
|
|
23
|
+
diff_engine: Engine for managing diffs (optional)
|
|
24
|
+
"""
|
|
25
|
+
self.store = store
|
|
26
|
+
self.diff_engine = diff_engine or DiffEngine()
|
|
27
|
+
|
|
28
|
+
def rename_symbol(self, old_name: str, new_name: str) -> RefactorPlan:
|
|
29
|
+
"""Rename a symbol and update all references.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
old_name: Current symbol name
|
|
33
|
+
new_name: New symbol name
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
RefactorPlan with all necessary changes
|
|
37
|
+
"""
|
|
38
|
+
# Find the symbol in the graph
|
|
39
|
+
node = self.store.get_node(old_name)
|
|
40
|
+
if not node:
|
|
41
|
+
raise ValueError(f"Symbol '{old_name}' not found in project")
|
|
42
|
+
|
|
43
|
+
# Find all call sites
|
|
44
|
+
call_sites = self.find_call_sites(old_name)
|
|
45
|
+
|
|
46
|
+
# Create changes for renaming
|
|
47
|
+
changes = []
|
|
48
|
+
files_to_update = set()
|
|
49
|
+
|
|
50
|
+
# Add the definition file
|
|
51
|
+
files_to_update.add(node["file_path"])
|
|
52
|
+
|
|
53
|
+
# Add all call site files
|
|
54
|
+
for location in call_sites:
|
|
55
|
+
files_to_update.add(location.file_path)
|
|
56
|
+
|
|
57
|
+
# Generate changes for each file
|
|
58
|
+
# Get project root from metadata
|
|
59
|
+
metadata = self.store.get_metadata()
|
|
60
|
+
project_root = Path(metadata.get("project_root", "."))
|
|
61
|
+
|
|
62
|
+
for file_path in files_to_update:
|
|
63
|
+
# Make path absolute
|
|
64
|
+
abs_path = project_root / file_path if not Path(file_path).is_absolute() else Path(file_path)
|
|
65
|
+
|
|
66
|
+
if not abs_path.exists():
|
|
67
|
+
continue # Skip non-existent files
|
|
68
|
+
|
|
69
|
+
original_content = abs_path.read_text()
|
|
70
|
+
new_content = self._rename_in_file(original_content, old_name, new_name)
|
|
71
|
+
|
|
72
|
+
if original_content != new_content:
|
|
73
|
+
changes.append(FileChange(
|
|
74
|
+
file_path=str(abs_path),
|
|
75
|
+
change_type="modify",
|
|
76
|
+
original_content=original_content,
|
|
77
|
+
new_content=new_content,
|
|
78
|
+
diff=self.diff_engine.create_diff(original_content, new_content, str(abs_path))
|
|
79
|
+
))
|
|
80
|
+
|
|
81
|
+
return RefactorPlan(
|
|
82
|
+
refactor_type="rename",
|
|
83
|
+
description=f"Rename '{old_name}' to '{new_name}'",
|
|
84
|
+
source_locations=[Location(node["file_path"], node["start_line"])],
|
|
85
|
+
target_location=Location(node["file_path"], node["start_line"]),
|
|
86
|
+
call_sites=call_sites,
|
|
87
|
+
changes=changes
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def extract_function(
|
|
91
|
+
self,
|
|
92
|
+
file_path: str,
|
|
93
|
+
start_line: int,
|
|
94
|
+
end_line: int,
|
|
95
|
+
function_name: str
|
|
96
|
+
) -> RefactorPlan:
|
|
97
|
+
"""Extract code range into a new function.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
file_path: File containing code to extract
|
|
101
|
+
start_line: Start line of code to extract
|
|
102
|
+
end_line: End line of code to extract
|
|
103
|
+
function_name: Name for the new function
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
RefactorPlan with extraction changes
|
|
107
|
+
"""
|
|
108
|
+
file_path_obj = Path(file_path)
|
|
109
|
+
if not file_path_obj.exists():
|
|
110
|
+
raise ValueError(f"File not found: {file_path}")
|
|
111
|
+
|
|
112
|
+
original_content = file_path_obj.read_text()
|
|
113
|
+
lines = original_content.splitlines(keepends=True)
|
|
114
|
+
|
|
115
|
+
# Extract the code block
|
|
116
|
+
extracted_lines = lines[start_line - 1:end_line]
|
|
117
|
+
extracted_code = "".join(extracted_lines)
|
|
118
|
+
|
|
119
|
+
# Analyze variables and detect parameters
|
|
120
|
+
indent = self._get_indent(extracted_lines[0]) if extracted_lines else " "
|
|
121
|
+
params = self._detect_parameters(extracted_code, original_content, start_line, end_line)
|
|
122
|
+
has_return = self._has_return_statement(extracted_code)
|
|
123
|
+
|
|
124
|
+
# Create new function with detected parameters
|
|
125
|
+
param_str = ", ".join(params) if params else ""
|
|
126
|
+
new_function = f"def {function_name}({param_str}):\n"
|
|
127
|
+
new_function += f"{indent}\"\"\"Extracted function.\"\"\"\n"
|
|
128
|
+
new_function += extracted_code
|
|
129
|
+
|
|
130
|
+
# Only add return None if no return statements found
|
|
131
|
+
if not has_return:
|
|
132
|
+
new_function += f"{indent}return None\n"
|
|
133
|
+
new_function += "\n\n"
|
|
134
|
+
|
|
135
|
+
# Replace extracted code with function call
|
|
136
|
+
call_args = ", ".join(params) if params else ""
|
|
137
|
+
if has_return:
|
|
138
|
+
replacement_line = f"{indent}result = {function_name}({call_args})\n"
|
|
139
|
+
replacement_line += f"{indent}if result:\n"
|
|
140
|
+
replacement_line += f"{indent} return result\n"
|
|
141
|
+
else:
|
|
142
|
+
replacement_line = f"{indent}{function_name}({call_args})\n"
|
|
143
|
+
|
|
144
|
+
# Find insertion point (before containing function)
|
|
145
|
+
insertion_line = self._find_function_start(lines, start_line)
|
|
146
|
+
|
|
147
|
+
# Build new content
|
|
148
|
+
new_lines = lines[:insertion_line]
|
|
149
|
+
new_lines.append(new_function)
|
|
150
|
+
new_lines.extend(lines[insertion_line:start_line - 1])
|
|
151
|
+
new_lines.append(replacement_line)
|
|
152
|
+
new_lines.extend(lines[end_line:])
|
|
153
|
+
|
|
154
|
+
new_content = "".join(new_lines)
|
|
155
|
+
|
|
156
|
+
changes = [FileChange(
|
|
157
|
+
file_path=file_path,
|
|
158
|
+
change_type="modify",
|
|
159
|
+
original_content=original_content,
|
|
160
|
+
new_content=new_content,
|
|
161
|
+
diff=self.diff_engine.create_diff(original_content, new_content, file_path)
|
|
162
|
+
)]
|
|
163
|
+
|
|
164
|
+
return RefactorPlan(
|
|
165
|
+
refactor_type="extract-function",
|
|
166
|
+
description=f"Extract lines {start_line}-{end_line} to function '{function_name}'",
|
|
167
|
+
source_locations=[Location(file_path, start_line)],
|
|
168
|
+
target_location=Location(file_path, start_line - 1),
|
|
169
|
+
call_sites=[],
|
|
170
|
+
changes=changes
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
def extract_service(
|
|
174
|
+
self,
|
|
175
|
+
symbols: List[str],
|
|
176
|
+
target_file: str
|
|
177
|
+
) -> RefactorPlan:
|
|
178
|
+
"""Extract multiple functions to a new service file.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
symbols: List of function names to extract
|
|
182
|
+
target_file: Path to new service file
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
RefactorPlan with extraction changes
|
|
186
|
+
"""
|
|
187
|
+
changes = []
|
|
188
|
+
source_locations = []
|
|
189
|
+
all_call_sites = []
|
|
190
|
+
|
|
191
|
+
# Collect all functions to extract
|
|
192
|
+
functions_code = []
|
|
193
|
+
source_files = set()
|
|
194
|
+
|
|
195
|
+
for symbol in symbols:
|
|
196
|
+
node = self.store.get_node(symbol)
|
|
197
|
+
if not node:
|
|
198
|
+
raise ValueError(f"Symbol '{symbol}' not found")
|
|
199
|
+
|
|
200
|
+
source_locations.append(Location(node["file_path"], node["start_line"]))
|
|
201
|
+
source_files.add(node["file_path"])
|
|
202
|
+
|
|
203
|
+
# Get function code
|
|
204
|
+
functions_code.append(node["code"])
|
|
205
|
+
|
|
206
|
+
# Find call sites
|
|
207
|
+
call_sites = self.find_call_sites(symbol)
|
|
208
|
+
all_call_sites.extend(call_sites)
|
|
209
|
+
|
|
210
|
+
# Create new service file
|
|
211
|
+
new_service_content = '"""Extracted service module."""\n\n'
|
|
212
|
+
new_service_content += "\n\n".join(functions_code)
|
|
213
|
+
|
|
214
|
+
changes.append(FileChange(
|
|
215
|
+
file_path=target_file,
|
|
216
|
+
change_type="create",
|
|
217
|
+
new_content=new_service_content
|
|
218
|
+
))
|
|
219
|
+
|
|
220
|
+
# Update source files to remove extracted functions and add imports
|
|
221
|
+
# Get project root from metadata
|
|
222
|
+
metadata = self.store.get_metadata()
|
|
223
|
+
project_root = Path(metadata.get("project_root", "."))
|
|
224
|
+
|
|
225
|
+
for source_file in source_files:
|
|
226
|
+
# Make path absolute
|
|
227
|
+
abs_path = project_root / source_file if not Path(source_file).is_absolute() else Path(source_file)
|
|
228
|
+
|
|
229
|
+
if not abs_path.exists():
|
|
230
|
+
continue
|
|
231
|
+
|
|
232
|
+
original_content = abs_path.read_text()
|
|
233
|
+
new_content = self._remove_functions_and_add_import(
|
|
234
|
+
original_content,
|
|
235
|
+
symbols,
|
|
236
|
+
target_file
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
if original_content != new_content:
|
|
240
|
+
changes.append(FileChange(
|
|
241
|
+
file_path=source_file,
|
|
242
|
+
change_type="modify",
|
|
243
|
+
original_content=original_content,
|
|
244
|
+
new_content=new_content,
|
|
245
|
+
diff=self.diff_engine.create_diff(original_content, new_content, source_file)
|
|
246
|
+
))
|
|
247
|
+
|
|
248
|
+
# Update call sites to use new import
|
|
249
|
+
call_site_files = {loc.file_path for loc in all_call_sites}
|
|
250
|
+
for call_site_file in call_site_files:
|
|
251
|
+
if call_site_file not in source_files:
|
|
252
|
+
# Make path absolute
|
|
253
|
+
abs_call_path = project_root / call_site_file if not Path(call_site_file).is_absolute() else Path(call_site_file)
|
|
254
|
+
|
|
255
|
+
if not abs_call_path.exists():
|
|
256
|
+
continue
|
|
257
|
+
|
|
258
|
+
original_content = abs_call_path.read_text()
|
|
259
|
+
new_content = self._add_import(original_content, symbols, target_file)
|
|
260
|
+
|
|
261
|
+
if original_content != new_content:
|
|
262
|
+
changes.append(FileChange(
|
|
263
|
+
file_path=call_site_file,
|
|
264
|
+
change_type="modify",
|
|
265
|
+
original_content=original_content,
|
|
266
|
+
new_content=new_content,
|
|
267
|
+
diff=self.diff_engine.create_diff(original_content, new_content, call_site_file)
|
|
268
|
+
))
|
|
269
|
+
|
|
270
|
+
return RefactorPlan(
|
|
271
|
+
refactor_type="extract-service",
|
|
272
|
+
description=f"Extract {len(symbols)} function(s) to {target_file}",
|
|
273
|
+
source_locations=source_locations,
|
|
274
|
+
target_location=Location(target_file, 1),
|
|
275
|
+
call_sites=all_call_sites,
|
|
276
|
+
changes=changes
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
def find_call_sites(self, symbol: str) -> List[Location]:
|
|
280
|
+
"""Find all locations where a symbol is called.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
symbol: Symbol name to find
|
|
284
|
+
|
|
285
|
+
Returns:
|
|
286
|
+
List of locations where symbol is used
|
|
287
|
+
"""
|
|
288
|
+
call_sites = []
|
|
289
|
+
|
|
290
|
+
# Use graph to find reverse dependencies
|
|
291
|
+
node = self.store.get_node(symbol)
|
|
292
|
+
if not node:
|
|
293
|
+
return call_sites
|
|
294
|
+
|
|
295
|
+
node_id = node["node_id"]
|
|
296
|
+
|
|
297
|
+
# Find all edges pointing to this node
|
|
298
|
+
# (This is a simplified implementation - would need reverse edge lookup)
|
|
299
|
+
all_nodes = self.store.get_nodes()
|
|
300
|
+
|
|
301
|
+
for other_node in all_nodes:
|
|
302
|
+
# Check if this node has edges to our target
|
|
303
|
+
edges = self.store.neighbors(other_node["node_id"])
|
|
304
|
+
for edge in edges:
|
|
305
|
+
if edge["dst"] == node_id:
|
|
306
|
+
call_sites.append(Location(
|
|
307
|
+
other_node["file_path"],
|
|
308
|
+
other_node["start_line"]
|
|
309
|
+
))
|
|
310
|
+
|
|
311
|
+
return call_sites
|
|
312
|
+
|
|
313
|
+
def _rename_in_file(self, content: str, old_name: str, new_name: str) -> str:
|
|
314
|
+
"""Rename symbol in file content.
|
|
315
|
+
|
|
316
|
+
Args:
|
|
317
|
+
content: File content
|
|
318
|
+
old_name: Old symbol name
|
|
319
|
+
new_name: New symbol name
|
|
320
|
+
|
|
321
|
+
Returns:
|
|
322
|
+
Updated content
|
|
323
|
+
"""
|
|
324
|
+
# Simple implementation: replace whole words only
|
|
325
|
+
# In production, would use AST-based renaming
|
|
326
|
+
import re
|
|
327
|
+
pattern = r'\b' + re.escape(old_name) + r'\b'
|
|
328
|
+
return re.sub(pattern, new_name, content)
|
|
329
|
+
|
|
330
|
+
def _get_indent(self, line: str) -> str:
|
|
331
|
+
"""Get indentation from a line."""
|
|
332
|
+
return line[:len(line) - len(line.lstrip())]
|
|
333
|
+
|
|
334
|
+
def _remove_functions_and_add_import(
|
|
335
|
+
self,
|
|
336
|
+
content: str,
|
|
337
|
+
symbols: List[str],
|
|
338
|
+
target_file: str
|
|
339
|
+
) -> str:
|
|
340
|
+
"""Remove functions from content and add import."""
|
|
341
|
+
# Simple implementation
|
|
342
|
+
# In production, would use AST manipulation
|
|
343
|
+
|
|
344
|
+
# Add import at top
|
|
345
|
+
module_name = Path(target_file).stem
|
|
346
|
+
import_line = f"from .{module_name} import {', '.join(symbols)}\n"
|
|
347
|
+
|
|
348
|
+
lines = content.splitlines(keepends=True)
|
|
349
|
+
|
|
350
|
+
# Find first non-import line
|
|
351
|
+
insert_pos = 0
|
|
352
|
+
for i, line in enumerate(lines):
|
|
353
|
+
if not line.strip().startswith(('import ', 'from ', '#', '"""', "'''")):
|
|
354
|
+
insert_pos = i
|
|
355
|
+
break
|
|
356
|
+
|
|
357
|
+
lines.insert(insert_pos, import_line)
|
|
358
|
+
|
|
359
|
+
# Remove function definitions (simplified)
|
|
360
|
+
# Would need proper AST-based removal in production
|
|
361
|
+
|
|
362
|
+
return "".join(lines)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _detect_parameters(self, extracted_code: str, full_content: str, start_line: int, end_line: int) -> List[str]:
|
|
366
|
+
"""Detect variables that should be parameters for extracted function.
|
|
367
|
+
|
|
368
|
+
Args:
|
|
369
|
+
extracted_code: Code being extracted
|
|
370
|
+
full_content: Full file content
|
|
371
|
+
start_line: Start line of extraction
|
|
372
|
+
end_line: End line of extraction
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
List of parameter names
|
|
376
|
+
"""
|
|
377
|
+
try:
|
|
378
|
+
# Parse extracted code to find used variables
|
|
379
|
+
tree = ast.parse(extracted_code)
|
|
380
|
+
used_names = set()
|
|
381
|
+
defined_names = set()
|
|
382
|
+
|
|
383
|
+
for node in ast.walk(tree):
|
|
384
|
+
if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load):
|
|
385
|
+
used_names.add(node.id)
|
|
386
|
+
elif isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store):
|
|
387
|
+
defined_names.add(node.id)
|
|
388
|
+
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
389
|
+
defined_names.add(node.name)
|
|
390
|
+
|
|
391
|
+
# Parameters are variables used but not defined in extracted code
|
|
392
|
+
# Filter out built-ins and common globals
|
|
393
|
+
builtins = {'True', 'False', 'None', 'print', 'len', 'range', 'str', 'int', 'list', 'dict', 'set'}
|
|
394
|
+
params = used_names - defined_names - builtins
|
|
395
|
+
|
|
396
|
+
return sorted(list(params))
|
|
397
|
+
except SyntaxError:
|
|
398
|
+
# If parsing fails, return empty list
|
|
399
|
+
return []
|
|
400
|
+
|
|
401
|
+
def _has_return_statement(self, code: str) -> bool:
|
|
402
|
+
"""Check if code contains return statements.
|
|
403
|
+
|
|
404
|
+
Args:
|
|
405
|
+
code: Code to check
|
|
406
|
+
|
|
407
|
+
Returns:
|
|
408
|
+
True if code has return statements
|
|
409
|
+
"""
|
|
410
|
+
try:
|
|
411
|
+
tree = ast.parse(code)
|
|
412
|
+
for node in ast.walk(tree):
|
|
413
|
+
if isinstance(node, ast.Return):
|
|
414
|
+
return True
|
|
415
|
+
return False
|
|
416
|
+
except SyntaxError:
|
|
417
|
+
return False
|
|
418
|
+
|
|
419
|
+
def _find_function_start(self, lines: List[str], current_line: int) -> int:
|
|
420
|
+
"""Find the start of the containing function.
|
|
421
|
+
|
|
422
|
+
Args:
|
|
423
|
+
lines: All lines in file
|
|
424
|
+
current_line: Current line number (1-indexed)
|
|
425
|
+
|
|
426
|
+
Returns:
|
|
427
|
+
Line number where containing function starts (0-indexed)
|
|
428
|
+
"""
|
|
429
|
+
# Search backwards for function definition
|
|
430
|
+
for i in range(current_line - 2, -1, -1):
|
|
431
|
+
line = lines[i].strip()
|
|
432
|
+
if line.startswith('def ') or line.startswith('async def '):
|
|
433
|
+
return i
|
|
434
|
+
|
|
435
|
+
# If no function found, insert at beginning
|
|
436
|
+
return 0
|
|
437
|
+
|
|
438
|
+
def _add_import(self, content: str, symbols: List[str], target_file: str) -> str:
|
|
439
|
+
"""Add import statement to content."""
|
|
440
|
+
module_name = Path(target_file).stem
|
|
441
|
+
import_line = f"from .{module_name} import {', '.join(symbols)}\n"
|
|
442
|
+
|
|
443
|
+
lines = content.splitlines(keepends=True)
|
|
444
|
+
|
|
445
|
+
# Find appropriate position for import
|
|
446
|
+
insert_pos = 0
|
|
447
|
+
for i, line in enumerate(lines):
|
|
448
|
+
if line.strip().startswith('from '):
|
|
449
|
+
insert_pos = i + 1
|
|
450
|
+
|
|
451
|
+
lines.insert(insert_pos, import_line)
|
|
452
|
+
return "".join(lines)
|