ai-codeindex 0.7.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.
- ai_codeindex-0.7.0.dist-info/METADATA +966 -0
- ai_codeindex-0.7.0.dist-info/RECORD +41 -0
- ai_codeindex-0.7.0.dist-info/WHEEL +4 -0
- ai_codeindex-0.7.0.dist-info/entry_points.txt +2 -0
- ai_codeindex-0.7.0.dist-info/licenses/LICENSE +21 -0
- codeindex/README_AI.md +767 -0
- codeindex/__init__.py +11 -0
- codeindex/adaptive_config.py +83 -0
- codeindex/adaptive_selector.py +171 -0
- codeindex/ai_helper.py +48 -0
- codeindex/cli.py +40 -0
- codeindex/cli_common.py +10 -0
- codeindex/cli_config.py +97 -0
- codeindex/cli_docs.py +66 -0
- codeindex/cli_hooks.py +765 -0
- codeindex/cli_scan.py +562 -0
- codeindex/cli_symbols.py +295 -0
- codeindex/cli_tech_debt.py +238 -0
- codeindex/config.py +479 -0
- codeindex/directory_tree.py +229 -0
- codeindex/docstring_processor.py +342 -0
- codeindex/errors.py +62 -0
- codeindex/extractors/__init__.py +9 -0
- codeindex/extractors/thinkphp.py +132 -0
- codeindex/file_classifier.py +148 -0
- codeindex/framework_detect.py +323 -0
- codeindex/hierarchical.py +428 -0
- codeindex/incremental.py +278 -0
- codeindex/invoker.py +260 -0
- codeindex/parallel.py +155 -0
- codeindex/parser.py +740 -0
- codeindex/route_extractor.py +98 -0
- codeindex/route_registry.py +77 -0
- codeindex/scanner.py +167 -0
- codeindex/semantic_extractor.py +408 -0
- codeindex/smart_writer.py +737 -0
- codeindex/symbol_index.py +199 -0
- codeindex/symbol_scorer.py +283 -0
- codeindex/tech_debt.py +619 -0
- codeindex/tech_debt_formatters.py +234 -0
- codeindex/writer.py +164 -0
|
@@ -0,0 +1,737 @@
|
|
|
1
|
+
"""Smart README writer with grouping, size limits, and hierarchical levels."""
|
|
2
|
+
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from fnmatch import fnmatch
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Literal, Optional
|
|
9
|
+
|
|
10
|
+
from .adaptive_selector import AdaptiveSymbolSelector
|
|
11
|
+
from .config import IndexingConfig
|
|
12
|
+
from .framework_detect import RouteInfo, detect_framework
|
|
13
|
+
from .parser import ParseResult, Symbol
|
|
14
|
+
from .semantic_extractor import SemanticExtractor
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class WriteResult:
|
|
19
|
+
"""Result of writing a README file."""
|
|
20
|
+
path: Path
|
|
21
|
+
success: bool
|
|
22
|
+
error: str = ""
|
|
23
|
+
size_bytes: int = 0
|
|
24
|
+
truncated: bool = False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# Level types
|
|
28
|
+
LevelType = Literal["overview", "navigation", "detailed"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SmartWriter:
|
|
32
|
+
"""
|
|
33
|
+
Smart README writer that generates appropriate content based on level.
|
|
34
|
+
|
|
35
|
+
Levels:
|
|
36
|
+
- overview: Project/root level, only module list with descriptions
|
|
37
|
+
- navigation: Module level, grouped files with key classes
|
|
38
|
+
- detailed: Leaf level, full symbol information
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, config: IndexingConfig, docstring_processor=None):
|
|
42
|
+
"""
|
|
43
|
+
Initialize SmartWriter.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
config: Indexing configuration (can also accept full Config object)
|
|
47
|
+
docstring_processor: Optional DocstringProcessor for AI-powered
|
|
48
|
+
docstring extraction (Epic 9)
|
|
49
|
+
"""
|
|
50
|
+
# Handle both IndexingConfig and full Config
|
|
51
|
+
if hasattr(config, 'indexing'):
|
|
52
|
+
# Full Config object passed - extract indexing config
|
|
53
|
+
self.config = config.indexing
|
|
54
|
+
else:
|
|
55
|
+
# IndexingConfig passed directly
|
|
56
|
+
self.config = config
|
|
57
|
+
|
|
58
|
+
self.max_size = self.config.max_readme_size
|
|
59
|
+
|
|
60
|
+
# Initialize adaptive symbol selector
|
|
61
|
+
self.adaptive_selector = AdaptiveSymbolSelector(
|
|
62
|
+
self.config.symbols.adaptive_symbols
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# Initialize semantic extractor if enabled
|
|
66
|
+
if self.config.semantic.enabled:
|
|
67
|
+
# Need ai_command from parent Config (will be passed separately)
|
|
68
|
+
# For now, initialize with heuristic mode
|
|
69
|
+
self.semantic_extractor = SemanticExtractor(
|
|
70
|
+
use_ai=self.config.semantic.use_ai,
|
|
71
|
+
ai_command=None if not self.config.semantic.use_ai else None
|
|
72
|
+
)
|
|
73
|
+
else:
|
|
74
|
+
self.semantic_extractor = None
|
|
75
|
+
|
|
76
|
+
# Initialize route extractor registry (Epic 6)
|
|
77
|
+
from .extractors.thinkphp import ThinkPHPRouteExtractor
|
|
78
|
+
from .route_registry import RouteExtractorRegistry
|
|
79
|
+
|
|
80
|
+
self.route_registry = RouteExtractorRegistry()
|
|
81
|
+
self.route_registry.register(ThinkPHPRouteExtractor())
|
|
82
|
+
|
|
83
|
+
# Initialize docstring processor (Epic 9)
|
|
84
|
+
self.docstring_processor = docstring_processor
|
|
85
|
+
|
|
86
|
+
def write_readme(
|
|
87
|
+
self,
|
|
88
|
+
dir_path: Path,
|
|
89
|
+
parse_results: list[ParseResult],
|
|
90
|
+
level: LevelType = "detailed",
|
|
91
|
+
child_dirs: list[Path] | None = None,
|
|
92
|
+
output_file: str = "README_AI.md",
|
|
93
|
+
) -> WriteResult:
|
|
94
|
+
"""
|
|
95
|
+
Write README_AI.md with appropriate content based on level.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
dir_path: Directory to write to
|
|
99
|
+
parse_results: Parsed file results for this directory
|
|
100
|
+
level: Content level (overview/navigation/detailed)
|
|
101
|
+
child_dirs: Child directories with their own README_AI.md
|
|
102
|
+
output_file: Output filename
|
|
103
|
+
"""
|
|
104
|
+
output_path = dir_path / output_file
|
|
105
|
+
child_dirs = child_dirs or []
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
if level == "overview":
|
|
109
|
+
content = self._generate_overview(dir_path, parse_results, child_dirs)
|
|
110
|
+
elif level == "navigation":
|
|
111
|
+
content = self._generate_navigation(dir_path, parse_results, child_dirs)
|
|
112
|
+
else: # detailed
|
|
113
|
+
content = self._generate_detailed(dir_path, parse_results, child_dirs)
|
|
114
|
+
|
|
115
|
+
# Check size limit
|
|
116
|
+
content_bytes = content.encode('utf-8')
|
|
117
|
+
truncated = False
|
|
118
|
+
if len(content_bytes) > self.max_size:
|
|
119
|
+
content, truncated = self._truncate_content(content, self.max_size)
|
|
120
|
+
content_bytes = content.encode('utf-8')
|
|
121
|
+
|
|
122
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
123
|
+
f.write(content)
|
|
124
|
+
|
|
125
|
+
return WriteResult(
|
|
126
|
+
path=output_path,
|
|
127
|
+
success=True,
|
|
128
|
+
size_bytes=len(content_bytes),
|
|
129
|
+
truncated=truncated,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
except Exception as e:
|
|
133
|
+
return WriteResult(path=output_path, success=False, error=str(e))
|
|
134
|
+
|
|
135
|
+
def _generate_overview(
|
|
136
|
+
self,
|
|
137
|
+
dir_path: Path,
|
|
138
|
+
parse_results: list[ParseResult],
|
|
139
|
+
child_dirs: list[Path],
|
|
140
|
+
) -> str:
|
|
141
|
+
"""Generate overview level README (root/project level)."""
|
|
142
|
+
timestamp = datetime.now().isoformat()
|
|
143
|
+
lines = [
|
|
144
|
+
f"<!-- Generated by codeindex (overview) at {timestamp} -->",
|
|
145
|
+
"",
|
|
146
|
+
f"# {dir_path.name}",
|
|
147
|
+
"",
|
|
148
|
+
]
|
|
149
|
+
|
|
150
|
+
# Detect framework
|
|
151
|
+
framework = detect_framework(dir_path)
|
|
152
|
+
if framework != "unknown":
|
|
153
|
+
lines.extend([
|
|
154
|
+
f"**Framework**: {framework.title()}",
|
|
155
|
+
"",
|
|
156
|
+
])
|
|
157
|
+
|
|
158
|
+
# Statistics
|
|
159
|
+
total_files = len(parse_results)
|
|
160
|
+
total_symbols = sum(len(r.symbols) for r in parse_results)
|
|
161
|
+
total_modules = len(child_dirs)
|
|
162
|
+
|
|
163
|
+
lines.extend([
|
|
164
|
+
"## Overview",
|
|
165
|
+
"",
|
|
166
|
+
f"- **Modules**: {total_modules}",
|
|
167
|
+
f"- **Files**: {total_files}",
|
|
168
|
+
f"- **Symbols**: {total_symbols}",
|
|
169
|
+
"",
|
|
170
|
+
])
|
|
171
|
+
|
|
172
|
+
# Module structure (tree view)
|
|
173
|
+
if child_dirs:
|
|
174
|
+
lines.extend([
|
|
175
|
+
"## Module Structure",
|
|
176
|
+
"",
|
|
177
|
+
"```",
|
|
178
|
+
f"{dir_path.name}/",
|
|
179
|
+
])
|
|
180
|
+
|
|
181
|
+
# Group child dirs by first-level subdirectory
|
|
182
|
+
for child in sorted(child_dirs):
|
|
183
|
+
rel_path = child.relative_to(dir_path)
|
|
184
|
+
lines.append(f"├── {rel_path}/")
|
|
185
|
+
|
|
186
|
+
lines.extend([
|
|
187
|
+
"```",
|
|
188
|
+
"",
|
|
189
|
+
])
|
|
190
|
+
|
|
191
|
+
# Module list with descriptions
|
|
192
|
+
lines.extend([
|
|
193
|
+
"## Modules",
|
|
194
|
+
"",
|
|
195
|
+
])
|
|
196
|
+
|
|
197
|
+
for child in sorted(child_dirs):
|
|
198
|
+
rel_path = child.relative_to(dir_path)
|
|
199
|
+
description = self._extract_module_description(child)
|
|
200
|
+
lines.append(f"- **{rel_path}** - {description}")
|
|
201
|
+
|
|
202
|
+
lines.append("")
|
|
203
|
+
|
|
204
|
+
return "\n".join(lines)
|
|
205
|
+
|
|
206
|
+
def _generate_navigation(
|
|
207
|
+
self,
|
|
208
|
+
dir_path: Path,
|
|
209
|
+
parse_results: list[ParseResult],
|
|
210
|
+
child_dirs: list[Path],
|
|
211
|
+
) -> str:
|
|
212
|
+
"""Generate navigation level README (module level)."""
|
|
213
|
+
timestamp = datetime.now().isoformat()
|
|
214
|
+
lines = [
|
|
215
|
+
f"<!-- Generated by codeindex (navigation) at {timestamp} -->",
|
|
216
|
+
"",
|
|
217
|
+
f"# {dir_path.name}",
|
|
218
|
+
"",
|
|
219
|
+
]
|
|
220
|
+
|
|
221
|
+
# Statistics
|
|
222
|
+
total_files = len(parse_results)
|
|
223
|
+
total_symbols = sum(len(r.symbols) for r in parse_results)
|
|
224
|
+
|
|
225
|
+
lines.extend([
|
|
226
|
+
"## Overview",
|
|
227
|
+
"",
|
|
228
|
+
f"- **Files**: {total_files}",
|
|
229
|
+
f"- **Symbols**: {total_symbols}",
|
|
230
|
+
f"- **Subdirectories**: {len(child_dirs)}",
|
|
231
|
+
"",
|
|
232
|
+
])
|
|
233
|
+
|
|
234
|
+
# Subdirectories (if any)
|
|
235
|
+
if child_dirs:
|
|
236
|
+
lines.extend([
|
|
237
|
+
"## Subdirectories",
|
|
238
|
+
"",
|
|
239
|
+
])
|
|
240
|
+
for child in sorted(child_dirs):
|
|
241
|
+
description = self._extract_module_description(child)
|
|
242
|
+
lines.append(f"- **{child.name}/** - {description}")
|
|
243
|
+
lines.extend(["", ""])
|
|
244
|
+
|
|
245
|
+
# Grouped files
|
|
246
|
+
if parse_results:
|
|
247
|
+
grouped = self._group_files(parse_results)
|
|
248
|
+
lines.extend([
|
|
249
|
+
"## Files",
|
|
250
|
+
"",
|
|
251
|
+
])
|
|
252
|
+
|
|
253
|
+
for group_name, group_results in grouped.items():
|
|
254
|
+
if group_name != "_ungrouped":
|
|
255
|
+
group_desc = self.config.grouping.patterns.get(group_name, "")
|
|
256
|
+
lines.append(f"### {group_name} ({len(group_results)} files)")
|
|
257
|
+
if group_desc:
|
|
258
|
+
lines.append(f"_{group_desc}_")
|
|
259
|
+
lines.append("")
|
|
260
|
+
|
|
261
|
+
for result in group_results:
|
|
262
|
+
if result.error:
|
|
263
|
+
continue
|
|
264
|
+
# List key classes/functions only
|
|
265
|
+
key_symbols = self._get_key_symbols(result.symbols)
|
|
266
|
+
symbol_summary = ", ".join(
|
|
267
|
+
s.name.split("::")[-1].split(".")[-1]
|
|
268
|
+
for s in key_symbols[:3]
|
|
269
|
+
)
|
|
270
|
+
if symbol_summary:
|
|
271
|
+
lines.append(f"- **{result.path.name}** - {symbol_summary}")
|
|
272
|
+
else:
|
|
273
|
+
lines.append(f"- {result.path.name}")
|
|
274
|
+
|
|
275
|
+
lines.append("")
|
|
276
|
+
|
|
277
|
+
return "\n".join(lines)
|
|
278
|
+
|
|
279
|
+
def _generate_detailed(
|
|
280
|
+
self,
|
|
281
|
+
dir_path: Path,
|
|
282
|
+
parse_results: list[ParseResult],
|
|
283
|
+
child_dirs: list[Path],
|
|
284
|
+
) -> str:
|
|
285
|
+
"""Generate detailed level README (leaf level)."""
|
|
286
|
+
timestamp = datetime.now().isoformat()
|
|
287
|
+
lines = [
|
|
288
|
+
f"<!-- Generated by codeindex (detailed) at {timestamp} -->",
|
|
289
|
+
"",
|
|
290
|
+
f"# {dir_path.name}",
|
|
291
|
+
"",
|
|
292
|
+
]
|
|
293
|
+
|
|
294
|
+
# Statistics
|
|
295
|
+
total_files = len(parse_results)
|
|
296
|
+
total_symbols = sum(len(r.symbols) for r in parse_results)
|
|
297
|
+
|
|
298
|
+
lines.extend([
|
|
299
|
+
"## Overview",
|
|
300
|
+
"",
|
|
301
|
+
f"- **Files**: {total_files}",
|
|
302
|
+
f"- **Symbols**: {total_symbols}",
|
|
303
|
+
"",
|
|
304
|
+
])
|
|
305
|
+
|
|
306
|
+
# Framework route tables (Epic 6: using registry)
|
|
307
|
+
# Try all registered extractors
|
|
308
|
+
from .route_extractor import ExtractionContext
|
|
309
|
+
|
|
310
|
+
for framework_name in self.route_registry.list_frameworks():
|
|
311
|
+
extractor = self.route_registry.get(framework_name)
|
|
312
|
+
if not extractor:
|
|
313
|
+
continue
|
|
314
|
+
|
|
315
|
+
# Create extraction context
|
|
316
|
+
# Note: root_path is approximated from dir_path
|
|
317
|
+
# In a real scenario, this would be passed from the caller
|
|
318
|
+
context = ExtractionContext(
|
|
319
|
+
root_path=dir_path, # Temporary: use current dir as root
|
|
320
|
+
current_dir=dir_path,
|
|
321
|
+
parse_results=parse_results,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
# Check if this extractor can handle this directory
|
|
325
|
+
if extractor.can_extract(context):
|
|
326
|
+
routes = extractor.extract_routes(context)
|
|
327
|
+
if routes:
|
|
328
|
+
route_lines = self._format_route_table(routes, framework_name)
|
|
329
|
+
lines.extend(route_lines)
|
|
330
|
+
break # Only use first matching extractor
|
|
331
|
+
|
|
332
|
+
# Subdirectories (brief, just references)
|
|
333
|
+
if child_dirs:
|
|
334
|
+
lines.extend([
|
|
335
|
+
"## Subdirectories",
|
|
336
|
+
"",
|
|
337
|
+
])
|
|
338
|
+
for child in sorted(child_dirs):
|
|
339
|
+
lines.append(f"- [{child.name}/]({child.name}/README_AI.md)")
|
|
340
|
+
lines.extend(["", ""])
|
|
341
|
+
|
|
342
|
+
# Process docstrings with AI if processor available (Epic 9)
|
|
343
|
+
if self.docstring_processor:
|
|
344
|
+
for result in parse_results:
|
|
345
|
+
if result.error or not result.symbols:
|
|
346
|
+
continue
|
|
347
|
+
|
|
348
|
+
# Get AI-enhanced docstrings for this file
|
|
349
|
+
try:
|
|
350
|
+
normalized = self.docstring_processor.process_file(
|
|
351
|
+
result.path, result.symbols
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
# Update symbol docstrings with AI-enhanced descriptions
|
|
355
|
+
for symbol in result.symbols:
|
|
356
|
+
if symbol.name in normalized:
|
|
357
|
+
symbol.docstring = normalized[symbol.name]
|
|
358
|
+
except Exception:
|
|
359
|
+
# If AI processing fails, continue with raw docstrings
|
|
360
|
+
# (backward compatible fallback)
|
|
361
|
+
pass
|
|
362
|
+
|
|
363
|
+
# Detailed file listing with symbols
|
|
364
|
+
if parse_results:
|
|
365
|
+
grouped = self._group_files(parse_results)
|
|
366
|
+
|
|
367
|
+
for group_name, group_results in grouped.items():
|
|
368
|
+
if group_name != "_ungrouped":
|
|
369
|
+
group_desc = self.config.grouping.patterns.get(group_name, "")
|
|
370
|
+
lines.append(f"## {group_name}")
|
|
371
|
+
if group_desc:
|
|
372
|
+
lines.append(f"_{group_desc}_")
|
|
373
|
+
lines.append("")
|
|
374
|
+
else:
|
|
375
|
+
lines.append("## Files")
|
|
376
|
+
lines.append("")
|
|
377
|
+
|
|
378
|
+
for result in group_results:
|
|
379
|
+
if result.error:
|
|
380
|
+
lines.append(f"### {result.path.name}")
|
|
381
|
+
lines.append(f"_Parse error: {result.error}_")
|
|
382
|
+
lines.append("")
|
|
383
|
+
continue
|
|
384
|
+
|
|
385
|
+
lines.append(f"### {result.path.name}")
|
|
386
|
+
|
|
387
|
+
# Show namespace for PHP files
|
|
388
|
+
if result.namespace:
|
|
389
|
+
lines.append(f"**Namespace:** `{result.namespace}`")
|
|
390
|
+
|
|
391
|
+
if result.module_docstring:
|
|
392
|
+
lines.append(f"_{result.module_docstring[:150]}_")
|
|
393
|
+
lines.append("")
|
|
394
|
+
|
|
395
|
+
# Filter and limit symbols
|
|
396
|
+
symbols = self._filter_symbols(result.symbols)
|
|
397
|
+
total_filtered_symbols = len(symbols) # Save count after filtering
|
|
398
|
+
|
|
399
|
+
# Calculate symbol limit: use adaptive if enabled, otherwise use max_per_file
|
|
400
|
+
if self.config.symbols.adaptive_symbols.enabled:
|
|
401
|
+
limit = self.adaptive_selector.calculate_limit(
|
|
402
|
+
result.file_lines, len(symbols)
|
|
403
|
+
)
|
|
404
|
+
else:
|
|
405
|
+
limit = self.config.symbols.max_per_file
|
|
406
|
+
|
|
407
|
+
symbols = symbols[:limit]
|
|
408
|
+
|
|
409
|
+
# Group by kind
|
|
410
|
+
classes = [s for s in symbols if s.kind == "class"]
|
|
411
|
+
methods = [s for s in symbols if s.kind == "method"]
|
|
412
|
+
functions = [s for s in symbols if s.kind == "function"]
|
|
413
|
+
properties = [s for s in symbols if s.kind == "property"]
|
|
414
|
+
|
|
415
|
+
if classes:
|
|
416
|
+
for cls in classes:
|
|
417
|
+
lines.append(f"**class** `{cls.signature}`")
|
|
418
|
+
if cls.docstring:
|
|
419
|
+
lines.append(f"> {cls.docstring[:100]}")
|
|
420
|
+
lines.append("")
|
|
421
|
+
|
|
422
|
+
if methods:
|
|
423
|
+
lines.append("**Methods:**")
|
|
424
|
+
for m in methods:
|
|
425
|
+
lines.append(f"- `{m.signature}`")
|
|
426
|
+
lines.append("")
|
|
427
|
+
|
|
428
|
+
if functions:
|
|
429
|
+
lines.append("**Functions:**")
|
|
430
|
+
for f in functions:
|
|
431
|
+
lines.append(f"- `{f.signature}`")
|
|
432
|
+
lines.append("")
|
|
433
|
+
|
|
434
|
+
if properties:
|
|
435
|
+
lines.append("**Properties:**")
|
|
436
|
+
for p in properties:
|
|
437
|
+
lines.append(f"- `{p.signature}`")
|
|
438
|
+
lines.append("")
|
|
439
|
+
|
|
440
|
+
# Show truncation notice
|
|
441
|
+
shown_symbols = len(symbols)
|
|
442
|
+
if shown_symbols < total_filtered_symbols:
|
|
443
|
+
lines.append(
|
|
444
|
+
f"_... and {total_filtered_symbols - shown_symbols} more symbols_"
|
|
445
|
+
)
|
|
446
|
+
lines.append("")
|
|
447
|
+
|
|
448
|
+
# Dependencies section
|
|
449
|
+
all_imports = []
|
|
450
|
+
for result in parse_results:
|
|
451
|
+
all_imports.extend(result.imports)
|
|
452
|
+
|
|
453
|
+
if all_imports:
|
|
454
|
+
lines.extend([
|
|
455
|
+
"## Dependencies",
|
|
456
|
+
"",
|
|
457
|
+
])
|
|
458
|
+
# Deduplicate and sort
|
|
459
|
+
modules = sorted(set(imp.module for imp in all_imports))
|
|
460
|
+
for module in modules[:20]: # Limit to 20
|
|
461
|
+
lines.append(f"- {module}")
|
|
462
|
+
if len(modules) > 20:
|
|
463
|
+
lines.append(f"_... and {len(modules) - 20} more_")
|
|
464
|
+
lines.append("")
|
|
465
|
+
|
|
466
|
+
return "\n".join(lines)
|
|
467
|
+
|
|
468
|
+
def _format_route_table(
|
|
469
|
+
self, routes: list[RouteInfo], framework: str = "thinkphp"
|
|
470
|
+
) -> list[str]:
|
|
471
|
+
"""
|
|
472
|
+
Format route information as Markdown table with line numbers.
|
|
473
|
+
|
|
474
|
+
Args:
|
|
475
|
+
routes: List of RouteInfo objects
|
|
476
|
+
framework: Framework name for title (e.g., "thinkphp", "laravel")
|
|
477
|
+
|
|
478
|
+
Returns:
|
|
479
|
+
List of markdown lines for the route table
|
|
480
|
+
|
|
481
|
+
Epic 6, P1: Line number support
|
|
482
|
+
"""
|
|
483
|
+
if not routes:
|
|
484
|
+
return []
|
|
485
|
+
|
|
486
|
+
# Format framework name with proper casing
|
|
487
|
+
framework_display = {
|
|
488
|
+
"thinkphp": "ThinkPHP",
|
|
489
|
+
"laravel": "Laravel",
|
|
490
|
+
"django": "Django",
|
|
491
|
+
"fastapi": "FastAPI",
|
|
492
|
+
}.get(framework.lower(), framework.title())
|
|
493
|
+
|
|
494
|
+
lines = [
|
|
495
|
+
f"## Routes ({framework_display})",
|
|
496
|
+
"",
|
|
497
|
+
"| URL | Controller | Action | Location | Description |",
|
|
498
|
+
"|-----|------------|--------|----------|-------------|",
|
|
499
|
+
]
|
|
500
|
+
|
|
501
|
+
# Display up to 30 routes
|
|
502
|
+
for route in routes[:30]:
|
|
503
|
+
# Use route.location property (handles file:line format)
|
|
504
|
+
location = f"`{route.location}`" if route.location else ""
|
|
505
|
+
|
|
506
|
+
# Get description (already truncated to 60 chars in extractor)
|
|
507
|
+
description = route.description if route.description else ""
|
|
508
|
+
|
|
509
|
+
lines.append(
|
|
510
|
+
f"| `{route.url}` | {route.controller} | "
|
|
511
|
+
f"{route.action} | {location} | {description} |"
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
# Show "more" indicator if there are additional routes
|
|
515
|
+
if len(routes) > 30:
|
|
516
|
+
remaining = len(routes) - 30
|
|
517
|
+
lines.append(f"| ... | _{remaining} more routes_ | | | |")
|
|
518
|
+
|
|
519
|
+
lines.extend(["", ""])
|
|
520
|
+
return lines
|
|
521
|
+
|
|
522
|
+
def _group_files(self, results: list[ParseResult]) -> dict[str, list[ParseResult]]:
|
|
523
|
+
"""Group files by suffix pattern."""
|
|
524
|
+
if not self.config.grouping.enabled:
|
|
525
|
+
return {"_ungrouped": results}
|
|
526
|
+
|
|
527
|
+
grouped = defaultdict(list)
|
|
528
|
+
ungrouped = []
|
|
529
|
+
|
|
530
|
+
for result in results:
|
|
531
|
+
filename = result.path.stem # Without extension
|
|
532
|
+
matched = False
|
|
533
|
+
|
|
534
|
+
for pattern in self.config.grouping.patterns.keys():
|
|
535
|
+
if filename.endswith(pattern):
|
|
536
|
+
grouped[pattern].append(result)
|
|
537
|
+
matched = True
|
|
538
|
+
break
|
|
539
|
+
|
|
540
|
+
if not matched:
|
|
541
|
+
ungrouped.append(result)
|
|
542
|
+
|
|
543
|
+
# Sort groups by pattern order, add ungrouped at end
|
|
544
|
+
ordered = {}
|
|
545
|
+
for pattern in self.config.grouping.patterns.keys():
|
|
546
|
+
if pattern in grouped:
|
|
547
|
+
ordered[pattern] = grouped[pattern]
|
|
548
|
+
|
|
549
|
+
if ungrouped:
|
|
550
|
+
ordered["_ungrouped"] = ungrouped
|
|
551
|
+
|
|
552
|
+
return ordered
|
|
553
|
+
|
|
554
|
+
def _filter_symbols(self, symbols: list[Symbol]) -> list[Symbol]:
|
|
555
|
+
"""Filter symbols based on visibility and exclusion patterns."""
|
|
556
|
+
filtered = []
|
|
557
|
+
|
|
558
|
+
for symbol in symbols:
|
|
559
|
+
# Check exclusion patterns
|
|
560
|
+
name = symbol.name.split("::")[-1].split(".")[-1]
|
|
561
|
+
excluded = False
|
|
562
|
+
for pattern in self.config.symbols.exclude_patterns:
|
|
563
|
+
if fnmatch(name, pattern):
|
|
564
|
+
excluded = True
|
|
565
|
+
break
|
|
566
|
+
|
|
567
|
+
if excluded:
|
|
568
|
+
continue
|
|
569
|
+
|
|
570
|
+
# Check visibility (from signature)
|
|
571
|
+
sig_lower = symbol.signature.lower()
|
|
572
|
+
if self.config.symbols.include_visibility:
|
|
573
|
+
# If visibility config exists, check it
|
|
574
|
+
has_visibility = any(v in sig_lower for v in ["public", "private", "protected"])
|
|
575
|
+
if has_visibility:
|
|
576
|
+
visible = any(v in sig_lower for v in self.config.symbols.include_visibility)
|
|
577
|
+
if not visible:
|
|
578
|
+
continue
|
|
579
|
+
|
|
580
|
+
filtered.append(symbol)
|
|
581
|
+
|
|
582
|
+
return filtered
|
|
583
|
+
|
|
584
|
+
def _get_key_symbols(self, symbols: list[Symbol]) -> list[Symbol]:
|
|
585
|
+
"""Get key symbols (classes and main functions) from a file."""
|
|
586
|
+
key = []
|
|
587
|
+
|
|
588
|
+
# Add all classes
|
|
589
|
+
for s in symbols:
|
|
590
|
+
if s.kind == "class":
|
|
591
|
+
key.append(s)
|
|
592
|
+
|
|
593
|
+
# Add public functions/methods
|
|
594
|
+
for s in symbols:
|
|
595
|
+
if s.kind in ("function", "method"):
|
|
596
|
+
sig_lower = s.signature.lower()
|
|
597
|
+
if "public" in sig_lower or s.kind == "function":
|
|
598
|
+
key.append(s)
|
|
599
|
+
|
|
600
|
+
return key[:5] # Limit to 5 key symbols
|
|
601
|
+
|
|
602
|
+
def _extract_module_description(self, dir_path: Path, output_file: str = "README_AI.md") -> str:
|
|
603
|
+
"""Extract brief description from a child module's README."""
|
|
604
|
+
readme_path = dir_path / output_file
|
|
605
|
+
if not readme_path.exists():
|
|
606
|
+
return "Module directory"
|
|
607
|
+
|
|
608
|
+
try:
|
|
609
|
+
content = readme_path.read_text(encoding="utf-8")
|
|
610
|
+
lines = content.split("\n")
|
|
611
|
+
|
|
612
|
+
# Look for first non-empty, non-header line
|
|
613
|
+
for line in lines[2:15]: # Skip header, check first 15 lines
|
|
614
|
+
line = line.strip()
|
|
615
|
+
if line and not line.startswith("#") and not line.startswith("<!--"):
|
|
616
|
+
if line.startswith("-"):
|
|
617
|
+
continue # Skip list items
|
|
618
|
+
return line[:80]
|
|
619
|
+
|
|
620
|
+
return "Module directory"
|
|
621
|
+
except Exception:
|
|
622
|
+
return "Module directory"
|
|
623
|
+
|
|
624
|
+
def _extract_module_description_semantic(
|
|
625
|
+
self,
|
|
626
|
+
dir_path: Path,
|
|
627
|
+
parse_result: Optional[ParseResult] = None
|
|
628
|
+
) -> str:
|
|
629
|
+
"""
|
|
630
|
+
Extract module description using semantic extraction.
|
|
631
|
+
|
|
632
|
+
Args:
|
|
633
|
+
dir_path: Path to the directory
|
|
634
|
+
parse_result: Optional ParseResult with symbols/imports
|
|
635
|
+
|
|
636
|
+
Returns:
|
|
637
|
+
Business semantic description
|
|
638
|
+
"""
|
|
639
|
+
if not self.semantic_extractor:
|
|
640
|
+
# Fallback to old method if semantic extraction disabled
|
|
641
|
+
return self._extract_module_description(dir_path)
|
|
642
|
+
|
|
643
|
+
# Build DirectoryContext from parse_result
|
|
644
|
+
from codeindex.semantic_extractor import DirectoryContext
|
|
645
|
+
|
|
646
|
+
# Get file names
|
|
647
|
+
files = []
|
|
648
|
+
if dir_path.is_dir():
|
|
649
|
+
files = [f.name for f in dir_path.iterdir() if f.is_file()]
|
|
650
|
+
|
|
651
|
+
# Get subdirectory names
|
|
652
|
+
subdirs = []
|
|
653
|
+
if dir_path.is_dir():
|
|
654
|
+
subdirs = [d.name for d in dir_path.iterdir() if d.is_dir()]
|
|
655
|
+
|
|
656
|
+
# Get symbols and imports from parse_result
|
|
657
|
+
symbols = []
|
|
658
|
+
imports = []
|
|
659
|
+
if parse_result:
|
|
660
|
+
symbols = [s.name for s in parse_result.symbols]
|
|
661
|
+
imports = [imp.module for imp in parse_result.imports]
|
|
662
|
+
|
|
663
|
+
# Create context
|
|
664
|
+
context = DirectoryContext(
|
|
665
|
+
path=str(dir_path),
|
|
666
|
+
files=files,
|
|
667
|
+
subdirs=subdirs,
|
|
668
|
+
symbols=symbols,
|
|
669
|
+
imports=imports
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
# Extract semantic
|
|
673
|
+
try:
|
|
674
|
+
semantic = self.semantic_extractor.extract_directory_semantic(context)
|
|
675
|
+
return semantic.description
|
|
676
|
+
except Exception:
|
|
677
|
+
# Fallback to old method on error
|
|
678
|
+
if self.config.semantic.fallback_to_heuristic:
|
|
679
|
+
return self._extract_module_description(dir_path)
|
|
680
|
+
return "Module directory"
|
|
681
|
+
|
|
682
|
+
def _truncate_content(self, content: str, max_size: int) -> tuple[str, bool]:
|
|
683
|
+
"""Truncate content to fit within size limit."""
|
|
684
|
+
content_bytes = content.encode('utf-8')
|
|
685
|
+
if len(content_bytes) <= max_size:
|
|
686
|
+
return content, False
|
|
687
|
+
|
|
688
|
+
# Find a good truncation point
|
|
689
|
+
truncated = content_bytes[:max_size - 200].decode('utf-8', errors='ignore')
|
|
690
|
+
|
|
691
|
+
# Try to truncate at a section boundary
|
|
692
|
+
last_section = truncated.rfind("\n## ")
|
|
693
|
+
if last_section > len(truncated) // 2:
|
|
694
|
+
truncated = truncated[:last_section]
|
|
695
|
+
|
|
696
|
+
# Add truncation notice
|
|
697
|
+
truncated += (
|
|
698
|
+
"\n\n---\n"
|
|
699
|
+
"_Content truncated due to size limit. "
|
|
700
|
+
"See individual module README files for details._\n"
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
return truncated, True
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def determine_level(
|
|
707
|
+
dir_path: Path,
|
|
708
|
+
root_path: Path,
|
|
709
|
+
has_children: bool,
|
|
710
|
+
config: IndexingConfig,
|
|
711
|
+
) -> LevelType:
|
|
712
|
+
"""
|
|
713
|
+
Determine the appropriate level for a directory.
|
|
714
|
+
|
|
715
|
+
Args:
|
|
716
|
+
dir_path: The directory being processed
|
|
717
|
+
root_path: The project root
|
|
718
|
+
has_children: Whether this directory has subdirectories with README_AI.md
|
|
719
|
+
config: Indexing configuration
|
|
720
|
+
"""
|
|
721
|
+
# Calculate depth from root
|
|
722
|
+
try:
|
|
723
|
+
rel_path = dir_path.relative_to(root_path)
|
|
724
|
+
depth = len(rel_path.parts)
|
|
725
|
+
except ValueError:
|
|
726
|
+
depth = 0
|
|
727
|
+
|
|
728
|
+
# Root directory
|
|
729
|
+
if depth == 0 or dir_path == root_path:
|
|
730
|
+
return config.root_level
|
|
731
|
+
|
|
732
|
+
# Has children -> module level
|
|
733
|
+
if has_children:
|
|
734
|
+
return config.module_level
|
|
735
|
+
|
|
736
|
+
# Leaf directory
|
|
737
|
+
return config.leaf_level
|