codegraphy-lite 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,330 @@
1
+ """Lightweight, safe directory and file scanner for CodeGraphy Lite.
2
+
3
+ Uses ONLY Python standard library `ast.parse()` and regex-based import matching
4
+ to discover files, extract signatures, and build interactive architecture graphs.
5
+ Enforces a hard limit of 35 files (open-source lite edition).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ast
11
+ import os
12
+ import re
13
+ from pathlib import Path
14
+ from typing import Dict, List, Optional, Set, Tuple
15
+
16
+ from codegraphy_lite.models import (
17
+ EdgeData,
18
+ FileReference,
19
+ GraphState,
20
+ NodeData,
21
+ Position,
22
+ ReactFlowNode,
23
+ ScannedEdgePreview,
24
+ ScannedTopicPreview,
25
+ ScanPreviewRequest,
26
+ ScanPreviewResponse,
27
+ Topic,
28
+ TopicLane,
29
+ WorkspaceModel,
30
+ new_id,
31
+ )
32
+
33
+ MAX_LITE_FILES = 35
34
+ SUPPORTED_EXTENSIONS = {".py", ".ts", ".tsx", ".js", ".jsx", ".json", ".md"}
35
+ DEFAULT_IGNORE_DIRS = {
36
+ ".git", ".venv", "venv", "node_modules", "__pycache__",
37
+ "dist", "build", "release", ".codegraphy", ".archflow",
38
+ ".idea", ".vscode", "coverage", ".pytest_cache"
39
+ }
40
+
41
+
42
+ def extract_ast_signatures(code: str) -> str:
43
+ """Extracts top-level class, function signatures, and docstrings using stdlib ast."""
44
+ try:
45
+ tree = ast.parse(code)
46
+ except Exception:
47
+ # Fallback for syntax errors or non-Python files
48
+ lines = [line.strip() for line in code.splitlines() if line.strip() and not line.strip().startswith("#")]
49
+ return "\n".join(lines[:30]) if lines else "# Empty or unparseable file"
50
+
51
+ parts: List[str] = []
52
+ doc = ast.get_docstring(tree)
53
+ if doc:
54
+ parts.append(f'"""{doc.strip()}"""\n')
55
+
56
+ for node in tree.body:
57
+ if isinstance(node, ast.ClassDef):
58
+ bases = [ast.unparse(b) for b in node.bases]
59
+ bases_str = f"({', '.join(bases)})" if bases else ""
60
+ parts.append(f"class {node.name}{bases_str}:")
61
+ cdoc = ast.get_docstring(node)
62
+ if cdoc:
63
+ parts.append(f' """{cdoc.strip()}"""')
64
+ for item in node.body:
65
+ if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
66
+ prefix = "async def" if isinstance(item, ast.AsyncFunctionDef) else "def"
67
+ try:
68
+ args = ast.unparse(item.args)
69
+ except Exception:
70
+ args = "..."
71
+ returns = f" -> {ast.unparse(item.returns)}" if getattr(item, "returns", None) else ""
72
+ parts.append(f" {prefix} {item.name}({args}){returns}: ...")
73
+ parts.append("")
74
+ elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
75
+ prefix = "async def" if isinstance(node, ast.AsyncFunctionDef) else "def"
76
+ try:
77
+ args = ast.unparse(node.args)
78
+ except Exception:
79
+ args = "..."
80
+ returns = f" -> {ast.unparse(node.returns)}" if getattr(node, "returns", None) else ""
81
+ fdoc = ast.get_docstring(node)
82
+ parts.append(f"{prefix} {node.name}({args}){returns}:")
83
+ if fdoc:
84
+ parts.append(f' """{fdoc.strip()}"""')
85
+ parts.append(" ...")
86
+ parts.append("")
87
+
88
+ return "\n".join(parts).strip() if parts else "# No top-level classes or functions found."
89
+
90
+
91
+ def extract_imports_from_python(code: str) -> List[str]:
92
+ """Extracts imported module names using stdlib ast."""
93
+ imported_modules: List[str] = []
94
+ try:
95
+ tree = ast.parse(code)
96
+ except Exception:
97
+ # Fallback to regex
98
+ for match in re.finditer(r"^(?:from\s+([\w\.]+)\s+import|import\s+([\w\.]+))", code, re.MULTILINE):
99
+ m = match.group(1) or match.group(2)
100
+ if m:
101
+ imported_modules.append(m)
102
+ return imported_modules
103
+
104
+ for node in ast.walk(tree):
105
+ if isinstance(node, ast.Import):
106
+ for alias in node.names:
107
+ imported_modules.append(alias.name)
108
+ elif isinstance(node, ast.ImportFrom):
109
+ if node.module:
110
+ imported_modules.append(node.module)
111
+ return imported_modules
112
+
113
+
114
+ def extract_imports_from_js_ts(code: str) -> List[str]:
115
+ """Extracts imports from JS/TS using simple regex."""
116
+ imported: List[str] = []
117
+ # match: import ... from '...' or require('...')
118
+ for match in re.finditer(r"""(?:import\s+.*?from\s+['"]([^'"]+)['"]|require\s*\(\s*['"]([^'"]+)['"]\))""", code):
119
+ mod = match.group(1) or match.group(2)
120
+ if mod and not mod.startswith("."):
121
+ imported.append(mod)
122
+ elif mod:
123
+ # relative path: ./foo/bar -> foo/bar
124
+ clean = mod.replace("\\", "/").lstrip("./")
125
+ imported.append(clean)
126
+ return imported
127
+
128
+
129
+ def collect_project_files(root: Path, ignore_dirs: Optional[Set[str]] = None, max_files: int = MAX_LITE_FILES) -> Tuple[List[Path], List[str]]:
130
+ """Walks the directory collecting up to max_files matching supported extensions."""
131
+ ignored = set(ignore_dirs) if ignore_dirs else set(DEFAULT_IGNORE_DIRS)
132
+ collected: List[Path] = []
133
+ triggered_ignores: Set[str] = set()
134
+
135
+ for dirpath, dirnames, filenames in os.walk(root):
136
+ # Exclude ignored directories in-place
137
+ removed = [d for d in dirnames if d in ignored or d.startswith(".")]
138
+ for r in removed:
139
+ triggered_ignores.add(r)
140
+ dirnames.remove(r)
141
+
142
+ # Sort filenames for deterministic ordering
143
+ filenames.sort()
144
+ for fname in filenames:
145
+ ext = Path(fname).suffix.lower()
146
+ if ext in SUPPORTED_EXTENSIONS:
147
+ collected.append(Path(dirpath) / fname)
148
+ if len(collected) >= max_files:
149
+ return collected, sorted(triggered_ignores)
150
+
151
+ return collected, sorted(triggered_ignores)
152
+
153
+
154
+ def infer_category(rel_path: str) -> str:
155
+ """Infers an architecture category for a file."""
156
+ p = rel_path.lower().replace("\\", "/")
157
+ if "api" in p or "route" in p or "controller" in p or "endpoint" in p:
158
+ return "api"
159
+ if "model" in p or "schema" in p or "entity" in p or "types" in p:
160
+ return "domain"
161
+ if "service" in p or "engine" in p or "handler" in p or "business" in p:
162
+ return "service"
163
+ if "db" in p or "storage" in p or "repo" in p or "sql" in p or "cache" in p:
164
+ return "data"
165
+ if "test" in p or "spec" in p:
166
+ return "tests"
167
+ if "ui" in p or "component" in p or "view" in p or "page" in p:
168
+ return "ui"
169
+ return "module"
170
+
171
+
172
+ def scan_project(root_dir: str | Path, max_files: int = MAX_LITE_FILES) -> ScanPreviewResponse:
173
+ """Scans project directory and builds ScanPreviewResponse (max 35 files)."""
174
+ root_path = Path(root_dir).resolve()
175
+ if not root_path.exists():
176
+ raise ValueError(f"Directory does not exist: {root_path}")
177
+
178
+ files, triggered_ignores = collect_project_files(root_path, max_files=max_files)
179
+
180
+ # 1. Build topics (one topic per file or submodule)
181
+ topics: List[ScannedTopicPreview] = []
182
+ file_to_topic_id: Dict[str, str] = {}
183
+ topic_files_map: Dict[str, List[str]] = {}
184
+
185
+ for file_p in files:
186
+ rel_posix = file_p.relative_to(root_path).as_posix()
187
+ # Topic ID based on relative path without extension
188
+ raw_id = rel_posix.replace("/", "_").replace("\\", "_")
189
+ if raw_id.endswith(".py"):
190
+ raw_id = raw_id[:-3]
191
+ elif "." in raw_id:
192
+ raw_id = raw_id.rsplit(".", 1)[0]
193
+
194
+ tid = raw_id.strip("_") or "root"
195
+ label = file_p.stem
196
+ cat = infer_category(rel_posix)
197
+
198
+ topics.append(
199
+ ScannedTopicPreview(
200
+ id=tid,
201
+ label=label,
202
+ category=cat,
203
+ files=[rel_posix],
204
+ )
205
+ )
206
+ file_to_topic_id[rel_posix] = tid
207
+ topic_files_map[tid] = [rel_posix]
208
+
209
+ # 2. Extract dependencies & create edges
210
+ edges: List[ScannedEdgePreview] = []
211
+ seen_edges: Set[Tuple[str, str]] = set()
212
+
213
+ for file_p in files:
214
+ rel_posix = file_p.relative_to(root_path).as_posix()
215
+ src_tid = file_to_topic_id.get(rel_posix)
216
+ if not src_tid:
217
+ continue
218
+
219
+ try:
220
+ content = file_p.read_text(encoding="utf-8", errors="ignore")
221
+ except Exception:
222
+ continue
223
+
224
+ if file_p.suffix == ".py":
225
+ imports = extract_imports_from_python(content)
226
+ elif file_p.suffix in {".js", ".ts", ".tsx", ".jsx"}:
227
+ imports = extract_imports_from_js_ts(content)
228
+ else:
229
+ imports = []
230
+
231
+ for imp in imports:
232
+ # Check if this import matches another scanned file
233
+ tgt_tid = None
234
+ imp_norm = imp.replace(".", "/").lower()
235
+ for other_rel, otid in file_to_topic_id.items():
236
+ if otid == src_tid:
237
+ continue
238
+ other_norm = other_rel.lower()
239
+ if (
240
+ imp_norm in other_norm
241
+ or other_norm.startswith(imp_norm)
242
+ or Path(other_rel).stem.lower() == imp.split(".")[-1].lower()
243
+ ):
244
+ tgt_tid = otid
245
+ break
246
+
247
+ if tgt_tid and (src_tid, tgt_tid) not in seen_edges:
248
+ seen_edges.add((src_tid, tgt_tid))
249
+ edges.append(
250
+ ScannedEdgePreview(
251
+ source_topic_id=src_tid,
252
+ target_topic_id=tgt_tid,
253
+ source_file=rel_posix,
254
+ target_module=imp,
255
+ )
256
+ )
257
+
258
+ # Count incoming / outgoing degrees
259
+ for edge in edges:
260
+ for t in topics:
261
+ if t.id == edge.source_topic_id:
262
+ t.outgoing_count += 1
263
+ elif t.id == edge.target_topic_id:
264
+ t.incoming_count += 1
265
+
266
+ return ScanPreviewResponse(
267
+ root_path=str(root_path),
268
+ project_name=root_path.name or "CodeGraphy Workspace",
269
+ total_files=len(files),
270
+ topics=topics,
271
+ edges=edges,
272
+ unresolved_imports=[],
273
+ ignored_dirs_triggered=triggered_ignores,
274
+ )
275
+
276
+
277
+ def build_graph_and_workspace(preview: ScanPreviewResponse) -> Tuple[GraphState, WorkspaceModel]:
278
+ """Converts a ScanPreviewResponse into React Flow GraphState and WorkspaceModel."""
279
+ nodes: List[ReactFlowNode] = []
280
+ domain_topics: List[Topic] = []
281
+ edges: List[EdgeData] = []
282
+
283
+ # Simple 2D Grid Layout (3 columns, rows down)
284
+ col_width = 320
285
+ row_height = 200
286
+ cols = 3
287
+
288
+ for idx, t in enumerate(preview.topics):
289
+ col = idx % cols
290
+ row = idx // cols
291
+ pos = Position(x=float(col * col_width + 50), y=float(row * row_height + 80))
292
+
293
+ attached = [FileReference(filepath=f) for f in t.files]
294
+ nd = NodeData(
295
+ id=t.id,
296
+ label=t.label,
297
+ description=f"{t.category.upper()} module · {len(t.files)} file(s)",
298
+ status="todo",
299
+ attached_files=attached,
300
+ )
301
+ nodes.append(ReactFlowNode(id=t.id, position=pos, data=nd))
302
+
303
+ domain_topics.append(
304
+ Topic(
305
+ id=t.id,
306
+ label=t.label,
307
+ description=f"{t.category.upper()} module",
308
+ status="todo",
309
+ position=pos,
310
+ attached_files=attached,
311
+ )
312
+ )
313
+
314
+ for idx, e in enumerate(preview.edges):
315
+ edges.append(
316
+ EdgeData(
317
+ id=f"edge_{e.source_topic_id}_{e.target_topic_id}_{idx}",
318
+ source=e.source_topic_id,
319
+ target=e.target_topic_id,
320
+ )
321
+ )
322
+
323
+ graph = GraphState(nodes=nodes, edges=edges)
324
+ ws = WorkspaceModel(
325
+ name=preview.project_name,
326
+ root_path=preview.root_path,
327
+ topics=domain_topics,
328
+ edges=edges,
329
+ )
330
+ return graph, ws