sourcecode 0.20.0__py3-none-any.whl → 0.21.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.
sourcecode/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """sourcecode — Genera mapas de contexto estructurado para agentes IA."""
2
2
 
3
- __version__ = "0.20.0"
3
+ __version__ = "0.21.0"
sourcecode/cli.py CHANGED
@@ -163,6 +163,10 @@ def main(
163
163
  ),
164
164
  ) -> None:
165
165
  """Genera un mapa de contexto estructurado del proyecto en formato JSON o YAML."""
166
+ # When a subcommand (e.g. prepare-context) is invoked, skip the main analysis.
167
+ if ctx.invoked_subcommand is not None:
168
+ return
169
+
166
170
  # Validar formato
167
171
  if format not in FORMAT_CHOICES:
168
172
  typer.echo(
@@ -654,31 +658,103 @@ def main(
654
658
 
655
659
  @app.command("prepare-context")
656
660
  def prepare_context_cmd(
657
- task: str = typer.Argument(..., help="Descripción de la tarea"),
658
- path: Path = typer.Option(".", "--path", "-p", help="Directorio del proyecto"),
661
+ task: Optional[str] = typer.Argument(
662
+ None,
663
+ help="Task: explain | fix-bug | refactor | generate-tests",
664
+ ),
665
+ path: Path = typer.Option(
666
+ Path("."),
667
+ "--path", "-p",
668
+ help="Project directory to analyze (default: current directory)",
669
+ ),
670
+ llm_prompt: bool = typer.Option(
671
+ False,
672
+ "--llm-prompt",
673
+ help="Append a ready-to-use LLM prompt to the output",
674
+ ),
675
+ task_help: bool = typer.Option(
676
+ False,
677
+ "--task-help",
678
+ help="List available tasks with descriptions and exit",
679
+ ),
680
+ dry_run: bool = typer.Option(
681
+ False,
682
+ "--dry-run",
683
+ help="Show what would be analyzed without running it",
684
+ ),
659
685
  ) -> None:
660
- """Prepara contexto mínimo optimizado para que un LLM modifique el código."""
661
- from dataclasses import asdict
686
+ """Prepare task-aware context optimized for LLM reasoning.
687
+
688
+ \b
689
+ Note: PATH must be provided before the subcommand (default: '.'):
690
+ sourcecode . prepare-context explain
691
+ sourcecode . prepare-context fix-bug --path /my/project
692
+ sourcecode . prepare-context generate-tests --llm-prompt
693
+ sourcecode . prepare-context --task-help
694
+ """
695
+ from sourcecode.prepare_context import TASKS, TaskContextBuilder
696
+
697
+ if task_help:
698
+ typer.echo("Available tasks:\n")
699
+ for name, spec in TASKS.items():
700
+ typer.echo(f" {name:<20} {spec.description}")
701
+ typer.echo(f" {'':20} Output: {spec.output_hint}\n")
702
+ raise typer.Exit()
662
703
 
663
- from sourcecode.prepare_context import ContextBuilder
704
+ if task is None:
705
+ typer.echo(
706
+ f"Error: task is required. Available: {', '.join(TASKS)}\n"
707
+ "Use --task-help for descriptions.",
708
+ err=True,
709
+ )
710
+ raise typer.Exit(code=1)
664
711
 
665
- target = path.resolve()
712
+ if task not in TASKS:
713
+ typer.echo(
714
+ f"Error: unknown task '{task}'. Available: {', '.join(TASKS)}",
715
+ err=True,
716
+ )
717
+ raise typer.Exit(code=1)
666
718
 
719
+ target = path.resolve()
667
720
  if not target.exists() or not target.is_dir():
668
721
  typer.echo(f"Error: '{target}' no es un directorio válido.", err=True)
669
722
  raise typer.Exit(code=1)
670
723
 
671
- builder = ContextBuilder(target)
672
- result = builder.prepare(task)
673
-
674
- out = {
675
- "task": result.task,
676
- "entry_points": result.entry_points,
677
- "relevant_files": [asdict(f) for f in result.relevant_files],
678
- "call_flow": result.call_flow,
679
- "snippets": [asdict(s) for s in result.snippets],
680
- "tests": result.tests,
681
- "notes": result.notes,
724
+ if dry_run:
725
+ spec = TASKS[task]
726
+ typer.echo(f"task: {task}")
727
+ typer.echo(f"goal: {spec.goal}")
728
+ typer.echo(f"path: {target}")
729
+ typer.echo(f"analyzers: dependencies={'yes' if spec.enable_dependencies else 'no'}"
730
+ f", code_notes={'yes' if spec.enable_code_notes else 'no'}")
731
+ typer.echo(f"output: {spec.output_hint}")
732
+ raise typer.Exit()
733
+
734
+ from dataclasses import asdict
735
+
736
+ builder = TaskContextBuilder(target)
737
+ output = builder.build(task)
738
+
739
+ out: dict[str, Any] = {
740
+ "task": output.task,
741
+ "goal": output.goal,
742
+ "project_summary": output.project_summary,
743
+ "architecture_summary": output.architecture_summary,
744
+ "relevant_files": [asdict(f) for f in output.relevant_files],
745
+ "key_dependencies": output.key_dependencies,
682
746
  }
747
+ if output.suspected_areas:
748
+ out["suspected_areas"] = output.suspected_areas
749
+ if output.improvement_opportunities:
750
+ out["improvement_opportunities"] = output.improvement_opportunities
751
+ if output.test_gaps:
752
+ out["test_gaps"] = output.test_gaps
753
+ if output.code_notes_summary:
754
+ out["code_notes_summary"] = output.code_notes_summary
755
+ if output.limitations:
756
+ out["limitations"] = output.limitations
757
+ if llm_prompt:
758
+ out["llm_prompt"] = builder.render_prompt(output)
683
759
 
684
760
  typer.echo(json.dumps(out, indent=2, ensure_ascii=False))
@@ -1,197 +1,486 @@
1
+ """prepare_context.py — Task-aware context optimizer for LLM reasoning."""
2
+
1
3
  from __future__ import annotations
2
4
 
3
5
  from dataclasses import dataclass
4
6
  from pathlib import Path
5
- from typing import Any, Dict, List, Optional
7
+ from typing import Any, Optional
6
8
 
7
- from sourcecode.semantic_analyzer import SemanticAnalyzer
8
9
 
10
+ # ─────────────────────────────────────────────────────────────────────────────
11
+ # Prompt templates
12
+ # ─────────────────────────────────────────────────────────────────────────────
9
13
 
10
- # =========================
11
- # Output schema (simple)
12
- # =========================
14
+ _EXPLAIN_PROMPT = """\
15
+ You are an expert software engineer. Your task is to explain this project clearly.
13
16
 
14
- @dataclass
15
- class ContextSnippet:
16
- file: str
17
- symbol: str
18
- code: str
19
- reason: str
17
+ ## Project Summary
18
+
19
+ {project_summary}
20
+
21
+ {architecture_section}
22
+
23
+ ## Key Files
24
+
25
+ {relevant_files_section}
26
+
27
+ {dependencies_section}
28
+
29
+ ## Instructions
30
+
31
+ 1. Summarize what this project does and who it is for.
32
+ 2. Describe the main components and how they interact.
33
+ 3. Identify the primary entry point and the main execution flow.
34
+ 4. Highlight any non-obvious design decisions or constraints.
35
+ """
36
+
37
+ _FIX_BUG_PROMPT = """\
38
+ You are an expert debugger. Your task is to identify and fix a bug in this codebase.
39
+
40
+ ## Project Summary
41
+
42
+ {project_summary}
43
+
44
+ ## Most Relevant Files
45
+
46
+ {relevant_files_section}
47
+
48
+ {suspected_areas_section}
49
+
50
+ {code_notes_section}
51
+
52
+ ## Instructions
53
+
54
+ 1. Review the relevant files listed above, paying close attention to suspected areas.
55
+ 2. Identify the root cause of the bug.
56
+ 3. Propose a minimal, targeted fix with a concrete code patch.
57
+ 4. Explain why the fix is correct and what side effects to watch for.
58
+ """
59
+
60
+ _REFACTOR_PROMPT = """\
61
+ You are an expert software engineer focused on code quality. \
62
+ Your task is to propose refactoring improvements.
63
+
64
+ ## Project Summary
65
+
66
+ {project_summary}
67
+
68
+ {architecture_section}
69
+
70
+ ## Files to Refactor
71
+
72
+ {relevant_files_section}
73
+
74
+ {improvement_opportunities_section}
75
+
76
+ ## Instructions
77
+
78
+ 1. Identify the top 3–5 most impactful refactoring opportunities.
79
+ 2. For each, describe the current problem, the proposed change, and the expected benefit.
80
+ 3. Prioritize changes that reduce complexity or improve testability.
81
+ 4. Do not suggest changes that break public APIs without noting the impact.
82
+ """
83
+
84
+ _GENERATE_TESTS_PROMPT = """\
85
+ You are an expert in software testing. Your task is to write tests for \
86
+ untested or undertested areas of this codebase.
87
+
88
+ ## Project Summary
89
+
90
+ {project_summary}
20
91
 
92
+ ## Files Needing Tests
93
+
94
+ {relevant_files_section}
95
+
96
+ {test_gaps_section}
97
+
98
+ {dependencies_section}
99
+
100
+ ## Instructions
101
+
102
+ 1. Write unit tests for the most critical untested functions and classes.
103
+ 2. Cover edge cases and error paths, not just the happy path.
104
+ 3. Use the same testing framework already present in the project.
105
+ 4. Each test must have a clear name that describes exactly what it verifies.
106
+ """
107
+
108
+
109
+ # ─────────────────────────────────────────────────────────────────────────────
110
+ # Task registry
111
+ # ─────────────────────────────────────────────────────────────────────────────
21
112
 
22
113
  @dataclass
23
- class ContextFile:
114
+ class TaskSpec:
115
+ name: str
116
+ goal: str
117
+ description: str
118
+ ranking_boosts: list[str]
119
+ ranking_penalties: list[str]
120
+ enable_code_notes: bool
121
+ enable_dependencies: bool
122
+ prompt_template: str
123
+ output_hint: str
124
+
125
+
126
+ TASKS: dict[str, TaskSpec] = {
127
+ "explain": TaskSpec(
128
+ name="explain",
129
+ goal="Generate a comprehensive project summary for onboarding an LLM or developer.",
130
+ description="Analyze project structure, entry points, and key dependencies.",
131
+ ranking_boosts=["main", "cli", "app", "core", "index", "readme", "schema", "config"],
132
+ ranking_penalties=["test_", "spec_", ".min.", "__pycache__", "docs/"],
133
+ enable_code_notes=False,
134
+ enable_dependencies=True,
135
+ prompt_template=_EXPLAIN_PROMPT,
136
+ output_hint="project_summary, architecture_summary, relevant_files, key_dependencies",
137
+ ),
138
+ "fix-bug": TaskSpec(
139
+ name="fix-bug",
140
+ goal="Identify the most likely files and areas where a bug may be located.",
141
+ description="Rank files by annotation density, surface TODOs/FIXMEs/BUGs.",
142
+ ranking_boosts=["handler", "service", "middleware", "router", "controller",
143
+ "processor", "parser", "validator"],
144
+ ranking_penalties=["test_", "spec_", ".min.", "__pycache__", "docs/"],
145
+ enable_code_notes=True,
146
+ enable_dependencies=False,
147
+ prompt_template=_FIX_BUG_PROMPT,
148
+ output_hint="relevant_files (ranked by risk), suspected_areas, code_notes_summary",
149
+ ),
150
+ "refactor": TaskSpec(
151
+ name="refactor",
152
+ goal="Highlight structural issues and improvement opportunities across the codebase.",
153
+ description="Surface large files, high-annotation areas, and architectural patterns.",
154
+ ranking_boosts=["core", "utils", "helper", "base", "common", "shared", "lib"],
155
+ ranking_penalties=["test_", ".min.", "__pycache__"],
156
+ enable_code_notes=True,
157
+ enable_dependencies=False,
158
+ prompt_template=_REFACTOR_PROMPT,
159
+ output_hint="relevant_files, improvement_opportunities, architecture_summary",
160
+ ),
161
+ "generate-tests": TaskSpec(
162
+ name="generate-tests",
163
+ goal="Identify untested source files and generate targeted test stubs.",
164
+ description="Find source files without matching test files and rank by complexity.",
165
+ ranking_boosts=["service", "handler", "controller", "router", "parser",
166
+ "validator", "processor"],
167
+ ranking_penalties=[".min.", "__pycache__", "docs/"],
168
+ enable_code_notes=False,
169
+ enable_dependencies=True,
170
+ prompt_template=_GENERATE_TESTS_PROMPT,
171
+ output_hint="test_gaps, relevant_files (source without tests), key_dependencies",
172
+ ),
173
+ }
174
+
175
+
176
+ # ─────────────────────────────────────────────────────────────────────────────
177
+ # Output schema
178
+ # ─────────────────────────────────────────────────────────────────────────────
179
+
180
+ @dataclass
181
+ class RelevantFile:
24
182
  path: str
25
- role: str
183
+ role: str # entrypoint | source | test
184
+ score: float
185
+ reason: str
26
186
 
27
187
 
28
188
  @dataclass
29
- class ContextResult:
189
+ class TaskOutput:
30
190
  task: str
31
- entry_points: List[Dict[str, str]]
32
- relevant_files: List[ContextFile]
33
- call_flow: List[Dict[str, Any]]
34
- snippets: List[ContextSnippet]
35
- tests: List[Dict[str, str]]
36
- notes: List[str]
191
+ goal: str
192
+ project_summary: Optional[str]
193
+ architecture_summary: Optional[str]
194
+ relevant_files: list[RelevantFile]
195
+ suspected_areas: list[str]
196
+ improvement_opportunities: list[str]
197
+ test_gaps: list[str]
198
+ key_dependencies: list[dict[str, Any]]
199
+ code_notes_summary: Optional[dict[str, Any]]
200
+ limitations: list[str]
201
+
202
+
203
+ # ─────────────────────────────────────────────────────────────────────────────
204
+ # Builder
205
+ # ─────────────────────────────────────────────────────────────────────────────
37
206
 
207
+ _SOURCE_EXTENSIONS: frozenset[str] = frozenset({
208
+ ".py", ".js", ".ts", ".tsx", ".jsx", ".java", ".kt",
209
+ ".go", ".rs", ".rb", ".php", ".cs", ".dart",
210
+ })
38
211
 
39
- # =========================
40
- # Core Builder
41
- # =========================
212
+ _ALL_EXTENSIONS: frozenset[str] = _SOURCE_EXTENSIONS | frozenset({
213
+ ".md", ".toml", ".yaml", ".yml", ".json", ".xml",
214
+ })
42
215
 
43
- class ContextBuilder:
216
+
217
+ class TaskContextBuilder:
44
218
  def __init__(self, root: Path) -> None:
45
219
  self.root = root
46
- self.analyzer = SemanticAnalyzer(root)
47
220
 
48
- # -------------------------
49
- # Public API
50
- # -------------------------
221
+ def build(self, task_name: str) -> TaskOutput:
222
+ if task_name not in TASKS:
223
+ raise ValueError(
224
+ f"Unknown task '{task_name}'. Available: {', '.join(TASKS)}"
225
+ )
226
+ spec = TASKS[task_name]
227
+
228
+ # ── 1. Scan ────────────────────────────────────────────────────────
229
+ from sourcecode.scanner import FileScanner
230
+ from sourcecode.tree_utils import flatten_file_tree
231
+
232
+ scanner = FileScanner(self.root, max_depth=6)
233
+ file_tree = scanner.scan_tree()
234
+ manifests = scanner.find_manifests()
235
+ all_paths = [p.replace("\\", "/") for p in flatten_file_tree(file_tree)]
236
+
237
+ # ── 2. Detect stacks + entry points ───────────────────────────────
238
+ from sourcecode.detectors import ProjectDetector, build_default_detectors
239
+
240
+ detector = ProjectDetector(build_default_detectors())
241
+ stacks, entry_points, _ = detector.detect(self.root, file_tree, manifests)
242
+ stacks, project_type = detector.classify_results(file_tree, stacks, entry_points)
243
+
244
+ # ── 3. Summarize ───────────────────────────────────────────────────
245
+ from sourcecode.schema import AnalysisMetadata, SourceMap
246
+ from sourcecode.summarizer import ProjectSummarizer
247
+ from sourcecode.architecture_summary import ArchitectureSummarizer
248
+
249
+ sm = SourceMap(
250
+ metadata=AnalysisMetadata(analyzed_path=str(self.root)),
251
+ file_tree=file_tree,
252
+ stacks=stacks,
253
+ project_type=project_type,
254
+ entry_points=entry_points,
255
+ )
256
+ sm.file_paths = all_paths
257
+
258
+ project_summary = ProjectSummarizer(self.root).generate(sm)
259
+ architecture_summary = ArchitectureSummarizer(self.root).generate(sm)
260
+
261
+ # ── 4. Dependencies ────────────────────────────────────────────────
262
+ key_dependencies: list[dict[str, Any]] = []
263
+ limitations: list[str] = []
264
+
265
+ if spec.enable_dependencies:
266
+ from dataclasses import asdict
267
+ from sourcecode.dependency_analyzer import DependencyAnalyzer
268
+
269
+ dep_records, dep_summary = DependencyAnalyzer().analyze(self.root)
270
+ primary_eco = stacks[0].stack if stacks else ""
271
+ direct = [
272
+ d for d in dep_records
273
+ if d.scope != "transitive" and d.source in {"manifest", "lockfile"}
274
+ ]
275
+ direct.sort(key=lambda d: (0 if d.ecosystem == primary_eco else 1, d.name.lower()))
276
+ key_dependencies = [asdict(d) for d in direct[:15]]
277
+ limitations.extend(dep_summary.limitations)
278
+
279
+ # ── 5. Code notes ──────────────────────────────────────────────────
280
+ code_notes_summary: Optional[dict[str, Any]] = None
281
+ suspected_areas: list[str] = []
282
+ improvement_opportunities: list[str] = []
283
+
284
+ if spec.enable_code_notes:
285
+ from dataclasses import asdict
286
+ from sourcecode.code_notes_analyzer import CodeNotesAnalyzer
287
+
288
+ cn_notes, _cn_adrs, cn_summary = CodeNotesAnalyzer().analyze(self.root)
289
+ code_notes_summary = asdict(cn_summary)
290
+
291
+ if task_name == "fix-bug":
292
+ bug_kinds = {"FIXME", "BUG", "HACK", "XXX"}
293
+ counts: dict[str, int] = {}
294
+ for note in cn_notes:
295
+ if note.kind in bug_kinds:
296
+ counts[note.path] = counts.get(note.path, 0) + 1
297
+ suspected_areas = [
298
+ f"{p} ({n} annotation{'s' if n > 1 else ''})"
299
+ for p, n in sorted(counts.items(), key=lambda x: -x[1])[:8]
300
+ ]
301
+
302
+ elif task_name == "refactor":
303
+ ref_kinds = {"TODO", "DEPRECATED", "OPTIMIZE", "HACK"}
304
+ counts2: dict[str, int] = {}
305
+ for note in cn_notes:
306
+ if note.kind in ref_kinds:
307
+ counts2[note.path] = counts2.get(note.path, 0) + 1
308
+ improvement_opportunities = [
309
+ f"{p}: {n} refactoring annotation{'s' if n > 1 else ''}"
310
+ for p, n in sorted(counts2.items(), key=lambda x: -x[1])[:8]
311
+ ]
312
+
313
+ # ── 6. Rank files ──────────────────────────────────────────────────
314
+ entry_set = {ep.path for ep in entry_points}
315
+ test_set = {p for p in all_paths if self._is_test(p)}
316
+ source_set = {p for p in all_paths if not self._is_test(p) and self._is_source(p)}
317
+
318
+ relevant_files = self._rank_files(
319
+ task_name, spec, all_paths, entry_set, test_set
320
+ )
321
+
322
+ # ── 7. Test gaps (generate-tests only) ────────────────────────────
323
+ test_gaps: list[str] = []
324
+ if task_name == "generate-tests":
325
+ test_stems = {
326
+ Path(p).stem.removeprefix("test_").removesuffix("_test")
327
+ for p in test_set
328
+ }
329
+ untested = [
330
+ p for p in source_set
331
+ if Path(p).stem not in test_stems
332
+ and not any(pen in p for pen in spec.ranking_penalties)
333
+ ]
334
+ untested.sort(key=lambda p: (len(p.split("/")), p))
335
+ test_gaps = untested[:15]
336
+
337
+ return TaskOutput(
338
+ task=task_name,
339
+ goal=spec.goal,
340
+ project_summary=project_summary,
341
+ architecture_summary=architecture_summary,
342
+ relevant_files=relevant_files,
343
+ suspected_areas=suspected_areas,
344
+ improvement_opportunities=improvement_opportunities,
345
+ test_gaps=test_gaps,
346
+ key_dependencies=key_dependencies,
347
+ code_notes_summary=code_notes_summary,
348
+ limitations=limitations,
349
+ )
51
350
 
52
- def prepare(self, task: str) -> ContextResult:
53
- """
54
- Entry point principal.
55
- """
56
- semantic = self.analyzer.analyze()
351
+ def render_prompt(self, output: TaskOutput) -> str:
352
+ spec = TASKS[output.task]
57
353
 
58
- entry_points = self._detect_entrypoints(semantic)
59
- relevant_files = self._select_relevant_files(task, entry_points)
60
- snippets = self._extract_snippets(relevant_files)
61
- tests = self._find_related_tests(relevant_files)
62
- call_flow = self._build_call_flow(entry_points)
354
+ def _section(title: str, body: str) -> str:
355
+ return f"## {title}\n\n{body}" if body.strip() else ""
63
356
 
64
- notes = self._generate_notes(task)
357
+ project_summary = output.project_summary or "No project summary available."
65
358
 
66
- return ContextResult(
67
- task=task,
68
- entry_points=entry_points,
69
- relevant_files=relevant_files,
70
- call_flow=call_flow,
71
- snippets=snippets,
72
- tests=tests,
73
- notes=notes,
359
+ architecture_section = _section(
360
+ "Architecture", output.architecture_summary or ""
74
361
  )
75
362
 
76
- # -------------------------
77
- # Heurísticas básicas
78
- # -------------------------
363
+ relevant_files_section = "\n".join(
364
+ f"- `{f.path}` [{f.role}] — {f.reason}"
365
+ for f in output.relevant_files
366
+ ) or "No relevant files identified."
367
+
368
+ deps_lines = [
369
+ f"- {d['name']} "
370
+ f"{d.get('declared_version') or d.get('resolved_version') or '(unknown)'}"
371
+ for d in output.key_dependencies[:10]
372
+ ]
373
+ dependencies_section = _section("Key Dependencies", "\n".join(deps_lines))
374
+
375
+ suspected_areas_section = _section(
376
+ "Suspected Problem Areas",
377
+ "\n".join(f"- {a}" for a in output.suspected_areas),
378
+ )
79
379
 
80
- def _detect_entrypoints(self, semantic: Any) -> List[Dict[str, str]]:
81
- """
82
- MVP: detecta cli.py o main functions
83
- """
84
- entrypoints: List[Dict[str, str]] = []
380
+ code_notes_section = ""
381
+ if output.code_notes_summary and output.code_notes_summary.get("total", 0) > 0:
382
+ by_kind = output.code_notes_summary.get("by_kind", {})
383
+ kinds_str = ", ".join(f"{k}: {v}" for k, v in by_kind.items() if v > 0)
384
+ top_files = ", ".join(output.code_notes_summary.get("top_files", [])[:5])
385
+ code_notes_section = _section(
386
+ "Code Annotations",
387
+ f"Total: {output.code_notes_summary['total']} ({kinds_str})\n"
388
+ f"Most annotated: {top_files}",
389
+ )
390
+
391
+ improvement_opportunities_section = _section(
392
+ "Improvement Opportunities",
393
+ "\n".join(f"- {o}" for o in output.improvement_opportunities),
394
+ )
85
395
 
86
- for path in semantic.file_paths:
87
- if path.endswith("cli.py") or path.endswith("__main__.py"):
88
- entrypoints.append({
89
- "file": path,
90
- "symbol": "main",
91
- "reason": "CLI entrypoint"
92
- })
396
+ test_gaps_section = _section(
397
+ "Untested Files",
398
+ "\n".join(f"- `{p}`" for p in output.test_gaps),
399
+ )
400
+
401
+ return spec.prompt_template.format(
402
+ project_summary=project_summary,
403
+ architecture_section=architecture_section,
404
+ relevant_files_section=relevant_files_section,
405
+ dependencies_section=dependencies_section,
406
+ suspected_areas_section=suspected_areas_section,
407
+ code_notes_section=code_notes_section,
408
+ improvement_opportunities_section=improvement_opportunities_section,
409
+ test_gaps_section=test_gaps_section,
410
+ ).strip()
93
411
 
94
- return entrypoints
412
+ # ── Helpers ───────────────────────────────────────────────────────────────
95
413
 
96
- def _select_relevant_files(
414
+ def _rank_files(
97
415
  self,
98
- task: str,
99
- entry_points: List[Dict[str, str]],
100
- ) -> List[ContextFile]:
101
- """
102
- Heurística simple:
103
- - incluir entrypoints
104
- - incluir archivos con palabras clave del task
105
- """
106
- files: List[ContextFile] = []
107
-
108
- for ep in entry_points:
109
- files.append(ContextFile(path=ep["file"], role="entrypoint"))
110
-
111
- keywords = task.lower().split()
112
-
113
- for path in self.analyzer.scan.file_paths: # fallback simple
114
- if any(k in path.lower() for k in keywords):
115
- files.append(ContextFile(path=path, role="matched"))
116
-
117
- # deduplicar
118
- seen = set()
119
- unique = []
120
- for f in files:
121
- if f.path not in seen:
122
- seen.add(f.path)
123
- unique.append(f)
124
-
125
- return unique[:8]
126
-
127
- def _extract_snippets(self, files: List[ContextFile]) -> List[ContextSnippet]:
128
- """
129
- Extrae primeras ~30 líneas como snippet simple.
130
- """
131
- snippets: List[ContextSnippet] = []
132
-
133
- for f in files:
134
- abs_path = self.root / f.path
135
- if not abs_path.exists():
416
+ task_name: str,
417
+ spec: TaskSpec,
418
+ all_paths: list[str],
419
+ entry_set: set[str],
420
+ test_set: set[str],
421
+ ) -> list[RelevantFile]:
422
+ scored: list[tuple[float, RelevantFile]] = []
423
+
424
+ for path in all_paths:
425
+ if Path(path).suffix.lower() not in _ALL_EXTENSIONS:
426
+ continue
427
+ if any(pen in path for pen in spec.ranking_penalties):
136
428
  continue
137
429
 
138
- try:
139
- content = abs_path.read_text(encoding="utf-8", errors="ignore")
140
- except Exception:
430
+ is_test = path in test_set
431
+ if is_test and task_name != "generate-tests":
141
432
  continue
142
433
 
143
- snippet = "\n".join(content.splitlines()[:30])
144
-
145
- snippets.append(ContextSnippet(
146
- file=f.path,
147
- symbol="module",
148
- code=snippet,
149
- reason=f"contenido inicial de {f.role}"
150
- ))
151
-
152
- return snippets[:12]
153
-
154
- def _find_related_tests(self, files: List[ContextFile]) -> List[Dict[str, str]]:
155
- """
156
- Busca tests por naming convention.
157
- """
158
- tests: List[Dict[str, str]] = []
159
-
160
- for f in files:
161
- name = Path(f.path).name
162
- test_name = f"test_{name}"
163
-
164
- test_path = self.root / "tests" / test_name
165
- if test_path.exists():
166
- tests.append({
167
- "file": f"tests/{test_name}",
168
- "reason": "posible test relacionado"
169
- })
170
-
171
- return tests
172
-
173
- def _build_call_flow(self, entry_points: List[Dict[str, str]]) -> List[Dict[str, Any]]:
174
- """
175
- MVP: placeholder (hasta que uses call graph real)
176
- """
177
- flow: List[Dict[str, Any]] = []
178
-
179
- for ep in entry_points:
180
- flow.append({
181
- "from": ep["symbol"],
182
- "to": ["unknown"]
183
- })
184
-
185
- return flow
186
-
187
- def _generate_notes(self, task: str) -> List[str]:
188
- notes: List[str] = []
189
-
190
- if "flag" in task or "--" in task:
191
- notes.append("probablemente necesitas modificar argumentos CLI")
192
- if "test" in task:
193
- notes.append("asegúrate de actualizar tests relacionados")
194
- if "refactor" in task:
195
- notes.append("mantener compatibilidad hacia atrás")
196
-
197
- return notes
434
+ score = 0.0
435
+ reasons: list[str] = []
436
+
437
+ if path in entry_set:
438
+ score += 3.0
439
+ reasons.append("entry point")
440
+
441
+ path_lower = path.lower()
442
+ for keyword in spec.ranking_boosts:
443
+ if keyword in path_lower:
444
+ score += 1.5
445
+ reasons.append(f"matches '{keyword}'")
446
+ break
447
+
448
+ if is_test:
449
+ score += 2.0
450
+ reasons.append("existing test")
451
+ elif self._is_source(path):
452
+ score += 0.5
453
+ if not reasons:
454
+ reasons.append("source file")
455
+
456
+ if score <= 0:
457
+ continue
458
+
459
+ role = (
460
+ "entrypoint" if path in entry_set
461
+ else ("test" if is_test else "source")
462
+ )
463
+ scored.append((score, RelevantFile(
464
+ path=path,
465
+ role=role,
466
+ score=round(score, 1),
467
+ reason=", ".join(reasons) if reasons else "source file",
468
+ )))
469
+
470
+ scored.sort(key=lambda x: -x[0])
471
+ return [f for _, f in scored[:15]]
472
+
473
+ def _is_test(self, path: str) -> bool:
474
+ name = Path(path).name.lower()
475
+ return (
476
+ name.startswith("test_")
477
+ or name.endswith("_test.py")
478
+ or name.endswith(".test.ts")
479
+ or name.endswith(".spec.ts")
480
+ or "/tests/" in path
481
+ or "/test/" in path
482
+ or "/spec/" in path
483
+ )
484
+
485
+ def _is_source(self, path: str) -> bool:
486
+ return Path(path).suffix.lower() in _SOURCE_EXTENSIONS
@@ -70,12 +70,14 @@ class SemanticAnalyzer:
70
70
 
71
71
  def __init__(
72
72
  self,
73
+ root: Optional[Path] = None,
73
74
  *,
74
75
  max_files: int = _MAX_FILES,
75
76
  max_file_size: int = _MAX_FILE_SIZE,
76
77
  max_calls: int = _MAX_CALLS,
77
78
  max_symbols: int = _MAX_SYMBOLS,
78
79
  ) -> None:
80
+ self.root = root
79
81
  self.max_files = max_files
80
82
  self.max_file_size = max_file_size
81
83
  self.max_calls = max_calls
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sourcecode
3
- Version: 0.20.0
3
+ Version: 0.21.0
4
4
  Summary: Genera un mapa de contexto estructurado de proyectos de software para agentes IA
5
5
  License: MIT
6
6
  Requires-Python: >=3.9
@@ -636,6 +636,75 @@ Combine flags for a comprehensive project handoff:
636
636
  sourcecode --compact --env-map --code-notes --git-context .
637
637
  ```
638
638
 
639
+ ## `prepare-context` — Task-aware context for LLMs
640
+
641
+ `prepare-context` is a subcommand that builds a focused, task-specific context optimized for LLM reasoning. Instead of a full project dump, it returns only the data an LLM needs for a specific goal.
642
+
643
+ > **Note:** Because `sourcecode` has a positional `PATH` argument, you must provide it explicitly before the subcommand:
644
+ > ```bash
645
+ > sourcecode . prepare-context <task> # current directory
646
+ > sourcecode /my/project prepare-context <task>
647
+ > ```
648
+
649
+ ### Available tasks
650
+
651
+ | Task | Goal | Output |
652
+ |------|------|--------|
653
+ | `explain` | Onboard an LLM to the project | `project_summary`, `architecture_summary`, `relevant_files`, `key_dependencies` |
654
+ | `fix-bug` | Identify likely bug locations | `relevant_files` (ranked by risk), `suspected_areas`, `code_notes_summary` |
655
+ | `refactor` | Surface improvement opportunities | `relevant_files`, `improvement_opportunities`, `architecture_summary` |
656
+ | `generate-tests` | Find untested areas | `test_gaps`, `relevant_files` (source without tests), `key_dependencies` |
657
+
658
+ ### Usage
659
+
660
+ ```bash
661
+ # List tasks with descriptions
662
+ sourcecode . prepare-context --task-help
663
+
664
+ # Explain the project
665
+ sourcecode . prepare-context explain
666
+
667
+ # Find bug areas, with a ready-to-paste LLM prompt
668
+ sourcecode . prepare-context fix-bug --llm-prompt
669
+
670
+ # Find untested files in a specific project
671
+ sourcecode . prepare-context generate-tests --path /my/project
672
+
673
+ # Preview what will be analyzed (no analysis run)
674
+ sourcecode . prepare-context refactor --dry-run
675
+ ```
676
+
677
+ ### Output format
678
+
679
+ ```json
680
+ {
681
+ "task": "fix-bug",
682
+ "goal": "Identify the most likely files and areas where a bug may be located.",
683
+ "project_summary": "CLI en Python (Typer). Entry points: src/cli.py. 4 dependencias.",
684
+ "architecture_summary": null,
685
+ "relevant_files": [
686
+ { "path": "src/handler.py", "role": "source", "score": 2.0, "reason": "matches 'handler'" },
687
+ { "path": "src/cli.py", "role": "entrypoint", "score": 3.0, "reason": "entry point" }
688
+ ],
689
+ "suspected_areas": ["src/handler.py (2 annotations)", "src/parser.py (1 annotation)"],
690
+ "code_notes_summary": { "total": 5, "by_kind": { "FIXME": 3, "BUG": 2 }, "top_files": ["src/handler.py"] }
691
+ }
692
+ ```
693
+
694
+ ### `--llm-prompt`
695
+
696
+ Add `--llm-prompt` to include a ready-to-use prompt that you can paste directly into any LLM:
697
+
698
+ ```bash
699
+ sourcecode . prepare-context explain --llm-prompt | jq -r '.llm_prompt'
700
+ ```
701
+
702
+ The prompt is task-specific and includes the project context, relevant files, and concrete instructions for the LLM.
703
+
704
+ ### Task auto-selection with `--agent`
705
+
706
+ For zero-config usage, `--agent` on the main command automatically selects the right flags for the project type. For task-aware context, use `prepare-context` explicitly with the task that matches your goal.
707
+
639
708
  ## Development
640
709
 
641
710
  Editable install with development dependencies:
@@ -1,8 +1,8 @@
1
- sourcecode/__init__.py,sha256=dzKShGXdjPL26nODQDz1WcEo-41T1Cel-cSD0xF9gPg,100
1
+ sourcecode/__init__.py,sha256=Wl0aL5XpGhZsWRJOan0FtriAIMa21IHgxlCXLjVcxOI,100
2
2
  sourcecode/architecture_analyzer.py,sha256=zxgqeqn2FhIUDn06X-whjGKiXbrHFkRlzZcr2zKd0Gw,16874
3
3
  sourcecode/architecture_summary.py,sha256=r9IciS9z-7h8xpiME7DKBLhLV36xzXtSzbZ1cRDWklo,11709
4
4
  sourcecode/classifier.py,sha256=fcp2wIq198wq3Rsh_bDmZIK_W5UI6FEL5oqm1RrQfpA,6846
5
- sourcecode/cli.py,sha256=_FKnSNHqtLH6wJ2IA_7NR7-TUICCitildIMpSZt4IP8,26360
5
+ sourcecode/cli.py,sha256=1ozxlC43rd2boOZbARb3l2csVpTFiBw8DRDzCtLywTI,29065
6
6
  sourcecode/code_notes_analyzer.py,sha256=rRd8bFYV0krjlxxQV0wenwE9K7pVpUQSR7KvSvUQKw4,9226
7
7
  sourcecode/coverage_parser.py,sha256=q0LeZJaX1bnntLu-ImksdBsMlpsVmk_iUfSaB4eaJGo,19702
8
8
  sourcecode/dependency_analyzer.py,sha256=2TYBIpenhfWf_TVBZZfq3T0SHFv8xCKFIyn1XnFHG6s,48146
@@ -11,11 +11,11 @@ sourcecode/env_analyzer.py,sha256=JZxBOuIxnM0xw0IaJFL6LiPJBErL848L3XoTOHGBqZI,13
11
11
  sourcecode/git_analyzer.py,sha256=S9PGt8RDasBQYQUsZh9-H_KWVlPvzwR4jgM7TKN9ZCk,7643
12
12
  sourcecode/graph_analyzer.py,sha256=cxl7Xb88aGxIVOCsatZJAE1CtgXGzIbJfqytV-9kkFs,45172
13
13
  sourcecode/metrics_analyzer.py,sha256=4uh11v-Q0gdrN87BOxuFWUym3N3AOkOuy21K5N8peB8,20126
14
- sourcecode/prepare_context.py,sha256=X43dEPe2cb7uMwlpQwVJMe0rQIZ0juaJ7H3F37sPbNc,5371
14
+ sourcecode/prepare_context.py,sha256=yPRohYZ3wvX156GQSo1vYuoCDBIG3uy3G-3DdNbGHKU,19271
15
15
  sourcecode/redactor.py,sha256=xuGcadGEHaPw4qZXlMDvzMCsr4VOkdp3oBQptHyJk8c,2884
16
16
  sourcecode/scanner.py,sha256=Ga4jDRAOhkcwlRmlE2ko6cE39qKy0aI4iE1lGBWmAlk,6410
17
17
  sourcecode/schema.py,sha256=dnNdnQxJtBv0PRVWi6-xnpr16dj6IsUGbso9PeeEolA,15743
18
- sourcecode/semantic_analyzer.py,sha256=SRQSGJzEi4ZsGvb7U3qvnRiKHabz4hvIzCqiG92x9K8,79349
18
+ sourcecode/semantic_analyzer.py,sha256=asQfJf-EhzYaOTA-iMuZsrVXtbW7SV2WEKCxgsxa88Y,79413
19
19
  sourcecode/serializer.py,sha256=ck0gK3PV-rY5UrAvykKh8x56Lb1135J23xzbcEsvUW8,13772
20
20
  sourcecode/summarizer.py,sha256=2Yz5xJ1bhtJLlrUpKcd74wijypRRo9GJmGT8u971EW0,11572
21
21
  sourcecode/tree_utils.py,sha256=xla45Whq24j6U9kEgTztJXkMpmOEDieAulKxP3nl89s,935
@@ -39,7 +39,7 @@ sourcecode/detectors/rust.py,sha256=V23NO6Y8NsrhrUlGC2feUEcNK63hf3gc1y3jI4tes1Q,
39
39
  sourcecode/detectors/systems.py,sha256=nYaKbGDFu0EOXFcd_1doWFT3tTUdkbxc2DjHUF5TcqQ,1627
40
40
  sourcecode/detectors/terraform.py,sha256=cxORPR_zVLOJpHlh4e9JnFpkQsn_UnqMMom5yG65hZ4,1693
41
41
  sourcecode/detectors/tooling.py,sha256=hIvop80No22pqyGVJ32NKliSdjkHRePQkIRroqG01bY,1875
42
- sourcecode-0.20.0.dist-info/METADATA,sha256=KzFy_Iwj3H4eEWomjdYtE_XMb94isl55ekXSIHKOQ58,27548
43
- sourcecode-0.20.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
44
- sourcecode-0.20.0.dist-info/entry_points.txt,sha256=voDtdlGU2-jR3wCZpQoOls_0eUKaDVCKIfajGHFDATs,50
45
- sourcecode-0.20.0.dist-info/RECORD,,
42
+ sourcecode-0.21.0.dist-info/METADATA,sha256=9gqiONC_cEbjYwoYox60Dx-2m8qzP3KwkNRqU8kis88,30326
43
+ sourcecode-0.21.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
44
+ sourcecode-0.21.0.dist-info/entry_points.txt,sha256=voDtdlGU2-jR3wCZpQoOls_0eUKaDVCKIfajGHFDATs,50
45
+ sourcecode-0.21.0.dist-info/RECORD,,