context-gen-cli 1.2.0__tar.gz → 1.2.4__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.
- {context_gen_cli-1.2.0 → context_gen_cli-1.2.4}/PKG-INFO +1 -1
- {context_gen_cli-1.2.0 → context_gen_cli-1.2.4}/context_gen/cli.py +23 -2
- context_gen_cli-1.2.4/context_gen/mcp/__init__.py +3 -0
- context_gen_cli-1.2.4/context_gen/mcp/advanced_tools.py +475 -0
- {context_gen_cli-1.2.0 → context_gen_cli-1.2.4}/context_gen/scanner.py +1 -1
- {context_gen_cli-1.2.0 → context_gen_cli-1.2.4}/context_gen_cli.egg-info/PKG-INFO +1 -1
- {context_gen_cli-1.2.0 → context_gen_cli-1.2.4}/context_gen_cli.egg-info/SOURCES.txt +4 -1
- {context_gen_cli-1.2.0 → context_gen_cli-1.2.4}/context_gen_cli.egg-info/entry_points.txt +1 -0
- {context_gen_cli-1.2.0 → context_gen_cli-1.2.4}/pyproject.toml +2 -1
- context_gen_cli-1.2.4/tests/test_advanced_tools.py +74 -0
- {context_gen_cli-1.2.0 → context_gen_cli-1.2.4}/README.md +0 -0
- {context_gen_cli-1.2.0 → context_gen_cli-1.2.4}/context_gen/__init__.py +0 -0
- {context_gen_cli-1.2.0 → context_gen_cli-1.2.4}/context_gen/extractor.py +0 -0
- {context_gen_cli-1.2.0 → context_gen_cli-1.2.4}/context_gen/generator.py +0 -0
- {context_gen_cli-1.2.0 → context_gen_cli-1.2.4}/context_gen/graph.py +0 -0
- {context_gen_cli-1.2.0 → context_gen_cli-1.2.4}/context_gen_cli.egg-info/dependency_links.txt +0 -0
- {context_gen_cli-1.2.0 → context_gen_cli-1.2.4}/context_gen_cli.egg-info/requires.txt +0 -0
- {context_gen_cli-1.2.0 → context_gen_cli-1.2.4}/context_gen_cli.egg-info/top_level.txt +0 -0
- {context_gen_cli-1.2.0 → context_gen_cli-1.2.4}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: context-gen-cli
|
|
3
|
-
Version: 1.2.
|
|
3
|
+
Version: 1.2.4
|
|
4
4
|
Summary: A modern CLI tool that scans any project, builds an AST-based dependency graph, and generates structured LLM context (agent.md / agent.json).
|
|
5
5
|
Author: yakkshit
|
|
6
6
|
License-Expression: MIT
|
|
@@ -166,7 +166,16 @@ def main(
|
|
|
166
166
|
path: Path = typer.Argument(
|
|
167
167
|
default=Path("."),
|
|
168
168
|
help="Target project directory to analyze.",
|
|
169
|
-
|
|
169
|
+
file_okay=False,
|
|
170
|
+
dir_okay=True,
|
|
171
|
+
readable=True,
|
|
172
|
+
resolve_path=True,
|
|
173
|
+
),
|
|
174
|
+
project: Optional[Path] = typer.Option(
|
|
175
|
+
None,
|
|
176
|
+
"--project",
|
|
177
|
+
"-p",
|
|
178
|
+
help="Target project directory to analyze.",
|
|
170
179
|
file_okay=False,
|
|
171
180
|
dir_okay=True,
|
|
172
181
|
readable=True,
|
|
@@ -185,6 +194,18 @@ def main(
|
|
|
185
194
|
help="Output format: 'all', 'md', 'json', or 'yaml'.",
|
|
186
195
|
case_sensitive=False,
|
|
187
196
|
),
|
|
197
|
+
mcp: bool = typer.Option(
|
|
198
|
+
False,
|
|
199
|
+
"--mcp",
|
|
200
|
+
"-m",
|
|
201
|
+
help="Start MCP server mode.",
|
|
202
|
+
),
|
|
203
|
+
interactive: bool = typer.Option(
|
|
204
|
+
False,
|
|
205
|
+
"--interactive",
|
|
206
|
+
"-i",
|
|
207
|
+
help="Run in interactive mode.",
|
|
208
|
+
),
|
|
188
209
|
output: Optional[Path] = typer.Option(
|
|
189
210
|
None,
|
|
190
211
|
"--output",
|
|
@@ -208,7 +229,7 @@ def main(
|
|
|
208
229
|
"""
|
|
209
230
|
Scan a project, build an AST-powered dependency graph, and generate LLM context.
|
|
210
231
|
"""
|
|
211
|
-
target_dir = path.resolve()
|
|
232
|
+
target_dir = (project or path).resolve()
|
|
212
233
|
|
|
213
234
|
# If -d / --default is supplied, generate all formats and keep watch running
|
|
214
235
|
if default_mode:
|
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
"""
|
|
2
|
+
context_gen.mcp.advanced_tools: Advanced MCP tools for deep code inspection, search, AST analysis, git diffs, and security validation.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import fnmatch
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import re
|
|
9
|
+
import subprocess
|
|
10
|
+
from typing import Any, Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
from context_gen.extractor import extract_metadata
|
|
13
|
+
from context_gen.graph import build_dependency_graph
|
|
14
|
+
from context_gen.scanner import scan_project
|
|
15
|
+
|
|
16
|
+
SENSITIVE_EXTENSIONS = {
|
|
17
|
+
".pem", ".key", ".p12", ".pfx", ".pkcs12", ".keystore",
|
|
18
|
+
".jks", ".ovpn", ".crt", ".der"
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
SENSITIVE_FILENAME_REGEX = re.compile(
|
|
22
|
+
r"^(id_rsa.*|id_ed25519.*|\.env(\..+)?|secrets?(\..+)?|credentials?(\..+)?)$",
|
|
23
|
+
re.IGNORECASE
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
CONTENT_SECRET_REGEXES = [
|
|
27
|
+
re.compile(r"-----BEGIN [A-Z0-9_-]+ PRIVATE KEY-----", re.IGNORECASE),
|
|
28
|
+
re.compile(r"-----BEGIN CERTIFICATE-----", re.IGNORECASE),
|
|
29
|
+
re.compile(r"ghp_[a-zA-Z0-9]{36}"),
|
|
30
|
+
re.compile(r"github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59}"),
|
|
31
|
+
re.compile(r"sk-[a-zA-Z0-9]{20,}"),
|
|
32
|
+
re.compile(r"xox[baprs]-[a-zA-Z0-9]{10,}"),
|
|
33
|
+
re.compile(r"AIza[0-9A-Za-z-_]{35}"),
|
|
34
|
+
re.compile(r"AKIA[0-9A-Z]{16}")
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
LANGUAGE_MAP = {
|
|
38
|
+
".py": "python",
|
|
39
|
+
".js": "javascript",
|
|
40
|
+
".jsx": "javascript",
|
|
41
|
+
".ts": "typescript",
|
|
42
|
+
".tsx": "typescript",
|
|
43
|
+
".json": "json",
|
|
44
|
+
".yaml": "yaml",
|
|
45
|
+
".yml": "yaml",
|
|
46
|
+
".md": "markdown",
|
|
47
|
+
".html": "html",
|
|
48
|
+
".css": "css",
|
|
49
|
+
".go": "go",
|
|
50
|
+
".rs": "rust",
|
|
51
|
+
".java": "java",
|
|
52
|
+
".c": "c",
|
|
53
|
+
".cpp": "cpp",
|
|
54
|
+
".sh": "bash"
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def is_sensitive_path(rel_path: str, full_path: Optional[Path] = None) -> Optional[str]:
|
|
59
|
+
"""Checks whether a file path or its content represents a sensitive secret."""
|
|
60
|
+
path_obj = Path(rel_path)
|
|
61
|
+
filename = path_obj.name
|
|
62
|
+
ext = path_obj.suffix.lower()
|
|
63
|
+
|
|
64
|
+
if ext in SENSITIVE_EXTENSIONS:
|
|
65
|
+
return f"Sensitive key/certificate extension ({ext})"
|
|
66
|
+
|
|
67
|
+
if SENSITIVE_FILENAME_REGEX.match(filename):
|
|
68
|
+
return f"Sensitive secret/environment file ({filename})"
|
|
69
|
+
|
|
70
|
+
if full_path and full_path.is_file():
|
|
71
|
+
try:
|
|
72
|
+
if full_path.stat().st_size <= 512 * 1024:
|
|
73
|
+
content = full_path.read_text(encoding="utf-8", errors="ignore")
|
|
74
|
+
for pattern in CONTENT_SECRET_REGEXES:
|
|
75
|
+
if pattern.search(content):
|
|
76
|
+
return "Contains embedded secret token or cryptographic private key"
|
|
77
|
+
except Exception:
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def get_code_snippet(
|
|
84
|
+
file_path: str,
|
|
85
|
+
function_name: Optional[str] = None,
|
|
86
|
+
class_name: Optional[str] = None,
|
|
87
|
+
start_line: Optional[int] = None,
|
|
88
|
+
end_line: Optional[int] = None,
|
|
89
|
+
project_path: str = "."
|
|
90
|
+
) -> Dict[str, Any]:
|
|
91
|
+
"""
|
|
92
|
+
Reads a file and extracts the requested section (by function/class name or line range).
|
|
93
|
+
Respects security boundaries.
|
|
94
|
+
"""
|
|
95
|
+
root = Path(project_path).resolve()
|
|
96
|
+
target = (root / file_path).resolve()
|
|
97
|
+
|
|
98
|
+
if not target.is_file():
|
|
99
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
|
100
|
+
|
|
101
|
+
rel_path = target.relative_to(root).as_posix()
|
|
102
|
+
sec_issue = is_sensitive_path(rel_path, target)
|
|
103
|
+
if sec_issue:
|
|
104
|
+
raise PermissionError(f"Access denied: {rel_path} is protected for security ({sec_issue})")
|
|
105
|
+
|
|
106
|
+
content = target.read_text(encoding="utf-8", errors="replace")
|
|
107
|
+
lines = content.splitlines(keepends=True)
|
|
108
|
+
ext = target.suffix.lower()
|
|
109
|
+
language = LANGUAGE_MAP.get(ext, "text")
|
|
110
|
+
|
|
111
|
+
# 1. Function / Class extraction using Python AST for Python files
|
|
112
|
+
if (function_name or class_name) and ext == ".py":
|
|
113
|
+
try:
|
|
114
|
+
tree = ast.parse(content, filename=str(target))
|
|
115
|
+
target_node = None
|
|
116
|
+
|
|
117
|
+
for node in ast.walk(tree):
|
|
118
|
+
if function_name and isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
119
|
+
if node.name == function_name:
|
|
120
|
+
target_node = node
|
|
121
|
+
break
|
|
122
|
+
elif class_name and isinstance(node, ast.ClassDef):
|
|
123
|
+
if node.name == class_name:
|
|
124
|
+
target_node = node
|
|
125
|
+
break
|
|
126
|
+
|
|
127
|
+
if target_node:
|
|
128
|
+
s_line = target_node.lineno
|
|
129
|
+
e_line = getattr(target_node, "end_lineno", s_line)
|
|
130
|
+
snippet_lines = lines[s_line - 1:e_line]
|
|
131
|
+
return {
|
|
132
|
+
"path": rel_path,
|
|
133
|
+
"content": "".join(snippet_lines),
|
|
134
|
+
"startLine": s_line,
|
|
135
|
+
"endLine": e_line,
|
|
136
|
+
"language": language
|
|
137
|
+
}
|
|
138
|
+
except SyntaxError:
|
|
139
|
+
pass
|
|
140
|
+
|
|
141
|
+
# Fallback for JS/TS/other languages or when AST match was not found
|
|
142
|
+
if function_name or class_name:
|
|
143
|
+
query_name = function_name or class_name
|
|
144
|
+
pattern = re.compile(rf"(?:def|function|class|const|let|var)\s+{re.escape(query_name)}\b")
|
|
145
|
+
found_start = None
|
|
146
|
+
for idx, line in enumerate(lines):
|
|
147
|
+
if pattern.search(line):
|
|
148
|
+
found_start = idx + 1
|
|
149
|
+
break
|
|
150
|
+
|
|
151
|
+
if found_start:
|
|
152
|
+
s_line = max(1, found_start)
|
|
153
|
+
e_line = min(len(lines), s_line + 40)
|
|
154
|
+
return {
|
|
155
|
+
"path": rel_path,
|
|
156
|
+
"content": "".join(lines[s_line - 1:e_line]),
|
|
157
|
+
"startLine": s_line,
|
|
158
|
+
"endLine": e_line,
|
|
159
|
+
"language": language
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
# 2. Line range extraction
|
|
163
|
+
s_line = max(1, start_line) if start_line is not None else 1
|
|
164
|
+
e_line = min(len(lines), end_line) if end_line is not None else len(lines)
|
|
165
|
+
|
|
166
|
+
snippet = "".join(lines[s_line - 1:e_line])
|
|
167
|
+
return {
|
|
168
|
+
"path": rel_path,
|
|
169
|
+
"content": snippet,
|
|
170
|
+
"startLine": s_line,
|
|
171
|
+
"endLine": e_line,
|
|
172
|
+
"language": language
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def search_code(
|
|
177
|
+
pattern: str,
|
|
178
|
+
file_pattern: Optional[str] = None,
|
|
179
|
+
case_sensitive: bool = False,
|
|
180
|
+
max_results: int = 50,
|
|
181
|
+
project_path: str = "."
|
|
182
|
+
) -> Dict[str, Any]:
|
|
183
|
+
"""
|
|
184
|
+
Grep-like search across all project files respecting security and ignore rules.
|
|
185
|
+
Limits to 50 results maximum.
|
|
186
|
+
"""
|
|
187
|
+
root = Path(project_path).resolve()
|
|
188
|
+
scanned = scan_project(str(root))
|
|
189
|
+
files = scanned.get("files", [])
|
|
190
|
+
|
|
191
|
+
flags = 0 if case_sensitive else re.IGNORECASE
|
|
192
|
+
try:
|
|
193
|
+
regex = re.compile(pattern, flags)
|
|
194
|
+
except re.error:
|
|
195
|
+
regex = re.compile(re.escape(pattern), flags)
|
|
196
|
+
|
|
197
|
+
matches: List[Dict[str, Any]] = []
|
|
198
|
+
limit = min(max_results, 50)
|
|
199
|
+
|
|
200
|
+
for rel_file in files:
|
|
201
|
+
if file_pattern and not fnmatch.fnmatch(rel_file, file_pattern):
|
|
202
|
+
continue
|
|
203
|
+
|
|
204
|
+
full_path = root / rel_file
|
|
205
|
+
if is_sensitive_path(rel_file, full_path):
|
|
206
|
+
continue
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
content = full_path.read_text(encoding="utf-8", errors="ignore")
|
|
210
|
+
lines = content.splitlines()
|
|
211
|
+
|
|
212
|
+
for idx, line in enumerate(lines):
|
|
213
|
+
if regex.search(line):
|
|
214
|
+
# Gather context (1 line before & after)
|
|
215
|
+
start_ctx = max(0, idx - 1)
|
|
216
|
+
end_ctx = min(len(lines), idx + 2)
|
|
217
|
+
context_block = "\n".join(lines[start_ctx:end_ctx])
|
|
218
|
+
|
|
219
|
+
matches.append({
|
|
220
|
+
"file": rel_file,
|
|
221
|
+
"line": line.strip(),
|
|
222
|
+
"lineNumber": idx + 1,
|
|
223
|
+
"context": context_block
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
if len(matches) >= limit:
|
|
227
|
+
return {"matches": matches, "total": len(matches), "capped": True}
|
|
228
|
+
except Exception:
|
|
229
|
+
continue
|
|
230
|
+
|
|
231
|
+
return {"matches": matches, "total": len(matches), "capped": False}
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def list_functions(
|
|
235
|
+
file_path: str,
|
|
236
|
+
project_path: str = "."
|
|
237
|
+
) -> Dict[str, Any]:
|
|
238
|
+
"""
|
|
239
|
+
Parses a file and lists all declared/exported functions, classes, and signatures.
|
|
240
|
+
Uses Python AST for 100% accurate Python parsing and regex heuristics for JS/TS.
|
|
241
|
+
"""
|
|
242
|
+
root = Path(project_path).resolve()
|
|
243
|
+
target = (root / file_path).resolve()
|
|
244
|
+
|
|
245
|
+
if not target.is_file():
|
|
246
|
+
raise FileNotFoundError(f"File not found: {file_path}")
|
|
247
|
+
|
|
248
|
+
rel_path = target.relative_to(root).as_posix()
|
|
249
|
+
sec_issue = is_sensitive_path(rel_path, target)
|
|
250
|
+
if sec_issue:
|
|
251
|
+
raise PermissionError(f"Access denied: {rel_path} is protected for security ({sec_issue})")
|
|
252
|
+
|
|
253
|
+
content = target.read_text(encoding="utf-8", errors="replace")
|
|
254
|
+
ext = target.suffix.lower()
|
|
255
|
+
functions: List[Dict[str, Any]] = []
|
|
256
|
+
|
|
257
|
+
# Python AST parsing
|
|
258
|
+
if ext == ".py":
|
|
259
|
+
try:
|
|
260
|
+
tree = ast.parse(content, filename=str(target))
|
|
261
|
+
for node in tree.body:
|
|
262
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
263
|
+
args = [a.arg for a in node.args.args]
|
|
264
|
+
sig = f"def {node.name}({', '.join(args)})"
|
|
265
|
+
doc = ast.get_docstring(node) or ""
|
|
266
|
+
functions.append({
|
|
267
|
+
"name": node.name,
|
|
268
|
+
"type": "async_function" if isinstance(node, ast.AsyncFunctionDef) else "function",
|
|
269
|
+
"line": node.lineno,
|
|
270
|
+
"signature": sig,
|
|
271
|
+
"description": doc.strip().split("\n")[0] if doc else ""
|
|
272
|
+
})
|
|
273
|
+
elif isinstance(node, ast.ClassDef):
|
|
274
|
+
doc = ast.get_docstring(node) or ""
|
|
275
|
+
functions.append({
|
|
276
|
+
"name": node.name,
|
|
277
|
+
"type": "class",
|
|
278
|
+
"line": node.lineno,
|
|
279
|
+
"signature": f"class {node.name}",
|
|
280
|
+
"description": doc.strip().split("\n")[0] if doc else ""
|
|
281
|
+
})
|
|
282
|
+
except SyntaxError:
|
|
283
|
+
pass
|
|
284
|
+
|
|
285
|
+
# JavaScript / TypeScript heuristic parsing
|
|
286
|
+
elif ext in {".js", ".jsx", ".ts", ".tsx", ".mjs"}:
|
|
287
|
+
lines = content.splitlines()
|
|
288
|
+
for idx, line in enumerate(lines):
|
|
289
|
+
line_no = idx + 1
|
|
290
|
+
# export function ...
|
|
291
|
+
m_func = re.search(r"export\s+(?:default\s+)?(?:async\s+)?function\s+([a-zA-Z0-9_$]+)\s*\((.*?)\)", line)
|
|
292
|
+
if m_func:
|
|
293
|
+
functions.append({
|
|
294
|
+
"name": m_func.group(1),
|
|
295
|
+
"type": "function",
|
|
296
|
+
"line": line_no,
|
|
297
|
+
"signature": f"function {m_func.group(1)}({m_func.group(2)})",
|
|
298
|
+
"description": "Exported function"
|
|
299
|
+
})
|
|
300
|
+
continue
|
|
301
|
+
|
|
302
|
+
# export const name = ...
|
|
303
|
+
m_const = re.search(r"export\s+const\s+([a-zA-Z0-9_$]+)\s*=\s*(?:async\s*)?\((.*?)\)\s*=>", line)
|
|
304
|
+
if m_const:
|
|
305
|
+
functions.append({
|
|
306
|
+
"name": m_const.group(1),
|
|
307
|
+
"type": "arrow_function",
|
|
308
|
+
"line": line_no,
|
|
309
|
+
"signature": f"const {m_const.group(1)} = ({m_const.group(2)}) =>",
|
|
310
|
+
"description": "Exported arrow function"
|
|
311
|
+
})
|
|
312
|
+
continue
|
|
313
|
+
|
|
314
|
+
# export class ...
|
|
315
|
+
m_class = re.search(r"export\s+(?:default\s+)?class\s+([a-zA-Z0-9_$]+)", line)
|
|
316
|
+
if m_class:
|
|
317
|
+
functions.append({
|
|
318
|
+
"name": m_class.group(1),
|
|
319
|
+
"type": "class",
|
|
320
|
+
"line": line_no,
|
|
321
|
+
"signature": f"class {m_class.group(1)}",
|
|
322
|
+
"description": "Exported class"
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
return {"functions": functions, "filePath": rel_path}
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def get_git_diff(
|
|
329
|
+
commits: int = 5,
|
|
330
|
+
file_path: Optional[str] = None,
|
|
331
|
+
project_path: str = "."
|
|
332
|
+
) -> Dict[str, Any]:
|
|
333
|
+
"""
|
|
334
|
+
Executes git log/diff safely to return recent changes and commits.
|
|
335
|
+
"""
|
|
336
|
+
root = Path(project_path).resolve()
|
|
337
|
+
commits_list: List[Dict[str, str]] = []
|
|
338
|
+
diff_output = ""
|
|
339
|
+
|
|
340
|
+
try:
|
|
341
|
+
# Get commit log
|
|
342
|
+
cmd_log = ["git", "log", f"-n{max(1, commits)}", "--pretty=format:%H|%an|%ad|%s", "--date=short"]
|
|
343
|
+
if file_path:
|
|
344
|
+
cmd_log.extend(["--", file_path])
|
|
345
|
+
|
|
346
|
+
res_log = subprocess.run(cmd_log, cwd=root, capture_output=True, text=True, check=False)
|
|
347
|
+
if res_log.returncode == 0 and res_log.stdout.strip():
|
|
348
|
+
for entry in res_log.stdout.strip().split("\n"):
|
|
349
|
+
parts = entry.split("|", 3)
|
|
350
|
+
if len(parts) == 4:
|
|
351
|
+
commits_list.append({
|
|
352
|
+
"hash": parts[0][:8],
|
|
353
|
+
"author": parts[1],
|
|
354
|
+
"date": parts[2],
|
|
355
|
+
"message": parts[3]
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
# Get diff of recent changes or unstaged changes
|
|
359
|
+
cmd_diff = ["git", "diff", "HEAD~1"] if commits_list else ["git", "diff"]
|
|
360
|
+
if file_path:
|
|
361
|
+
cmd_diff.extend(["--", file_path])
|
|
362
|
+
|
|
363
|
+
res_diff = subprocess.run(cmd_diff, cwd=root, capture_output=True, text=True, check=False)
|
|
364
|
+
if res_diff.returncode == 0:
|
|
365
|
+
diff_output = res_diff.stdout[:8000]
|
|
366
|
+
else:
|
|
367
|
+
diff_output = "No active diff or repository not initialized."
|
|
368
|
+
except Exception as err:
|
|
369
|
+
diff_output = f"Git execution error: {str(err)}"
|
|
370
|
+
|
|
371
|
+
return {
|
|
372
|
+
"diff": diff_output,
|
|
373
|
+
"commits": commits_list
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def explain_architecture(
|
|
378
|
+
depth: str = "high",
|
|
379
|
+
project_path: str = "."
|
|
380
|
+
) -> Dict[str, Any]:
|
|
381
|
+
"""
|
|
382
|
+
Generates a natural language architecture summary from graph and metadata.
|
|
383
|
+
Supported depths: 'high', 'medium', 'deep'.
|
|
384
|
+
"""
|
|
385
|
+
root = Path(project_path).resolve()
|
|
386
|
+
scanned = scan_project(str(root))
|
|
387
|
+
files = scanned.get("files", [])
|
|
388
|
+
metadata = extract_metadata(str(root), files)
|
|
389
|
+
graph = build_dependency_graph(files, str(root))
|
|
390
|
+
|
|
391
|
+
stack = ", ".join(metadata.get("techStack", [])) or "General Codebase"
|
|
392
|
+
nodes = graph.get("nodes", [])
|
|
393
|
+
edges = graph.get("edges", [])
|
|
394
|
+
|
|
395
|
+
entry_points = graph.get("entryPoints") or [n.get("file") or n.get("id") for n in nodes if n.get("type") == "entry" or n.get("isEntry")]
|
|
396
|
+
util_nodes = graph.get("utilities") or [n.get("file") or n.get("id") for n in nodes if n.get("type") == "util" or "util" in (n.get("file") or n.get("id") or "").lower()]
|
|
397
|
+
entry_points = [e for e in entry_points if e]
|
|
398
|
+
util_nodes = [u for u in util_nodes if u]
|
|
399
|
+
|
|
400
|
+
diagram = ""
|
|
401
|
+
|
|
402
|
+
if depth == "high":
|
|
403
|
+
summary = (
|
|
404
|
+
f"This project is a {metadata.get('name', root.name)} codebase built primarily with {stack}. "
|
|
405
|
+
f"It comprises {len(files)} source files organized across {len(nodes)} modules with {len(edges)} "
|
|
406
|
+
f"inter-module dependency connections. Key entry points include: {', '.join(entry_points) or 'main modules'}."
|
|
407
|
+
)
|
|
408
|
+
elif depth == "medium":
|
|
409
|
+
summary = (
|
|
410
|
+
f"### Architecture Overview: {metadata.get('name', root.name)}\n\n"
|
|
411
|
+
f"- **Primary Stack**: {stack}\n"
|
|
412
|
+
f"- **Entry Points**: {', '.join(entry_points) if entry_points else 'Root entry files'}\n"
|
|
413
|
+
f"- **Core Modules**: {len(nodes)} modules mapped in the dependency graph\n"
|
|
414
|
+
f"- **Utilities & Helpers**: {len(util_nodes)} utility modules detected\n\n"
|
|
415
|
+
f"The application follows a modular structure where entry modules coordinate domain functionality "
|
|
416
|
+
f"supported by core abstractions and shared utility libraries."
|
|
417
|
+
)
|
|
418
|
+
else: # deep
|
|
419
|
+
# Generate Mermaid diagram snippet
|
|
420
|
+
mermaid_lines = ["graph TD"]
|
|
421
|
+
for edge in edges[:25]:
|
|
422
|
+
src_val = edge.get("from") or edge.get("source") or ""
|
|
423
|
+
tgt_val = edge.get("to") or edge.get("target") or ""
|
|
424
|
+
src = Path(src_val).name.replace(".", "_")
|
|
425
|
+
tgt = Path(tgt_val).name.replace(".", "_")
|
|
426
|
+
if src and tgt:
|
|
427
|
+
mermaid_lines.append(f" {src} --> {tgt}")
|
|
428
|
+
|
|
429
|
+
diagram = "\n".join(mermaid_lines)
|
|
430
|
+
summary = (
|
|
431
|
+
f"### In-Depth Architectural & Graph Analysis\n\n"
|
|
432
|
+
f"**Workspace**: `{root.as_posix()}`\n"
|
|
433
|
+
f"**Technologies**: {stack}\n"
|
|
434
|
+
f"**Component Graph**: {len(nodes)} nodes, {len(edges)} directed dependency edges\n\n"
|
|
435
|
+
f"#### Module Classification\n"
|
|
436
|
+
f"- **Entry Points**: {len(entry_points)} ({', '.join(entry_points[:5])})\n"
|
|
437
|
+
f"- **Utilities & Shared**: {len(util_nodes)} ({', '.join(util_nodes[:5])})\n"
|
|
438
|
+
f"- **Graph Density**: {(len(edges) / max(1, len(nodes))):.2f} average connections per node."
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
return {
|
|
442
|
+
"summary": summary,
|
|
443
|
+
"diagrams": diagram if diagram else None
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def validate_security(
|
|
448
|
+
project_path: str = "."
|
|
449
|
+
) -> Dict[str, Any]:
|
|
450
|
+
"""
|
|
451
|
+
Runs the security validator across all project files and reports sensitive files or embedded secrets.
|
|
452
|
+
"""
|
|
453
|
+
root = Path(project_path).resolve()
|
|
454
|
+
scanned = scan_project(str(root))
|
|
455
|
+
files = scanned.get("files", [])
|
|
456
|
+
|
|
457
|
+
issues: List[Dict[str, Any]] = []
|
|
458
|
+
|
|
459
|
+
for rel_file in files:
|
|
460
|
+
full_path = root / rel_file
|
|
461
|
+
reason = is_sensitive_path(rel_file, full_path)
|
|
462
|
+
if reason:
|
|
463
|
+
severity = "HIGH" if "key" in reason.lower() or "secret" in reason.lower() else "MEDIUM"
|
|
464
|
+
issues.append({
|
|
465
|
+
"file": rel_file,
|
|
466
|
+
"reason": reason,
|
|
467
|
+
"severity": severity
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
return {
|
|
471
|
+
"safe": len(issues) == 0,
|
|
472
|
+
"issues": issues,
|
|
473
|
+
"totalScanned": len(files),
|
|
474
|
+
"totalIssues": len(issues)
|
|
475
|
+
}
|
|
@@ -72,7 +72,7 @@ def load_gitignore(root: Path) -> pathspec.PathSpec:
|
|
|
72
72
|
except Exception:
|
|
73
73
|
pass
|
|
74
74
|
|
|
75
|
-
return pathspec.PathSpec.from_lines("
|
|
75
|
+
return pathspec.PathSpec.from_lines("gitignore", patterns)
|
|
76
76
|
|
|
77
77
|
|
|
78
78
|
def generate_tree(root: Path, spec: pathspec.PathSpec, max_depth: int = 3) -> str:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: context-gen-cli
|
|
3
|
-
Version: 1.2.
|
|
3
|
+
Version: 1.2.4
|
|
4
4
|
Summary: A modern CLI tool that scans any project, builds an AST-based dependency graph, and generates structured LLM context (agent.md / agent.json).
|
|
5
5
|
Author: yakkshit
|
|
6
6
|
License-Expression: MIT
|
|
@@ -6,9 +6,12 @@ context_gen/extractor.py
|
|
|
6
6
|
context_gen/generator.py
|
|
7
7
|
context_gen/graph.py
|
|
8
8
|
context_gen/scanner.py
|
|
9
|
+
context_gen/mcp/__init__.py
|
|
10
|
+
context_gen/mcp/advanced_tools.py
|
|
9
11
|
context_gen_cli.egg-info/PKG-INFO
|
|
10
12
|
context_gen_cli.egg-info/SOURCES.txt
|
|
11
13
|
context_gen_cli.egg-info/dependency_links.txt
|
|
12
14
|
context_gen_cli.egg-info/entry_points.txt
|
|
13
15
|
context_gen_cli.egg-info/requires.txt
|
|
14
|
-
context_gen_cli.egg-info/top_level.txt
|
|
16
|
+
context_gen_cli.egg-info/top_level.txt
|
|
17
|
+
tests/test_advanced_tools.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "context-gen-cli"
|
|
7
|
-
version = "1.2.
|
|
7
|
+
version = "1.2.4"
|
|
8
8
|
description = "A modern CLI tool that scans any project, builds an AST-based dependency graph, and generates structured LLM context (agent.md / agent.json)."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
authors = [{ name = "yakkshit" }]
|
|
@@ -39,6 +39,7 @@ dev = [
|
|
|
39
39
|
|
|
40
40
|
[project.scripts]
|
|
41
41
|
context-gen = "context_gen.cli:app"
|
|
42
|
+
ctx = "context_gen.cli:app"
|
|
42
43
|
|
|
43
44
|
[tool.setuptools.packages.find]
|
|
44
45
|
where = ["."]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unit tests for context_gen.mcp.advanced_tools
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from context_gen.mcp.advanced_tools import (
|
|
10
|
+
get_code_snippet,
|
|
11
|
+
search_code,
|
|
12
|
+
list_functions,
|
|
13
|
+
get_git_diff,
|
|
14
|
+
explain_architecture,
|
|
15
|
+
validate_security
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_get_code_snippet_function():
|
|
20
|
+
res = get_code_snippet(
|
|
21
|
+
file_path="context_gen/scanner.py",
|
|
22
|
+
function_name="scan_project",
|
|
23
|
+
project_path=str(Path(__file__).parent.parent)
|
|
24
|
+
)
|
|
25
|
+
assert res["path"] == "context_gen/scanner.py"
|
|
26
|
+
assert "def scan_project" in res["content"]
|
|
27
|
+
assert res["language"] == "python"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_get_code_snippet_lines():
|
|
31
|
+
res = get_code_snippet(
|
|
32
|
+
file_path="context_gen/scanner.py",
|
|
33
|
+
start_line=1,
|
|
34
|
+
end_line=10,
|
|
35
|
+
project_path=str(Path(__file__).parent.parent)
|
|
36
|
+
)
|
|
37
|
+
assert res["startLine"] == 1
|
|
38
|
+
assert res["endLine"] == 10
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_search_code():
|
|
42
|
+
res = search_code(
|
|
43
|
+
pattern="scan_project",
|
|
44
|
+
project_path=str(Path(__file__).parent.parent)
|
|
45
|
+
)
|
|
46
|
+
assert len(res["matches"]) > 0
|
|
47
|
+
assert any("scan_project" in m["line"] for m in res["matches"])
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_list_functions():
|
|
51
|
+
res = list_functions(
|
|
52
|
+
file_path="context_gen/scanner.py",
|
|
53
|
+
project_path=str(Path(__file__).parent.parent)
|
|
54
|
+
)
|
|
55
|
+
func_names = [f["name"] for f in res["functions"]]
|
|
56
|
+
assert "scan_project" in func_names
|
|
57
|
+
assert "load_gitignore" in func_names
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_explain_architecture():
|
|
61
|
+
res = explain_architecture(
|
|
62
|
+
depth="high",
|
|
63
|
+
project_path=str(Path(__file__).parent.parent)
|
|
64
|
+
)
|
|
65
|
+
assert "summary" in res
|
|
66
|
+
assert len(res["summary"]) > 0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_validate_security():
|
|
70
|
+
res = validate_security(
|
|
71
|
+
project_path=str(Path(__file__).parent.parent)
|
|
72
|
+
)
|
|
73
|
+
assert "safe" in res
|
|
74
|
+
assert "issues" in res
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{context_gen_cli-1.2.0 → context_gen_cli-1.2.4}/context_gen_cli.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|