sourcecode 0.19.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.19.0"
3
+ __version__ = "0.21.0"
sourcecode/cli.py CHANGED
@@ -75,6 +75,11 @@ def main(
75
75
  "--graph-edges",
76
76
  help="Tipos de arista para `--graph-modules` separados por comas: imports,calls,contains,extends",
77
77
  ),
78
+ no_tree: bool = typer.Option(
79
+ False,
80
+ "--no-tree",
81
+ help="Suprimir file_tree y file_paths del output (ideal con --dependencies en proyectos grandes)",
82
+ ),
78
83
  no_redact: bool = typer.Option(
79
84
  False,
80
85
  "--no-redact",
@@ -151,8 +156,17 @@ def main(
151
156
  "--code-notes",
152
157
  help="Extraer anotaciones TODO/FIXME/HACK/NOTE/DEPRECATED/WARNING/BUG/XXX/OPTIMIZE con ubicacion y simbolo envolvente, y detectar ADRs en docs/decisions/, docs/adr/ y similares",
153
158
  ),
159
+ agent: bool = typer.Option(
160
+ False,
161
+ "--agent",
162
+ help="Modo agente: selecciona automaticamente --compact --dependencies --env-map --code-notes y activa --no-tree en proyectos Java/Gradle o grandes",
163
+ ),
154
164
  ) -> None:
155
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
+
156
170
  # Validar formato
157
171
  if format not in FORMAT_CHOICES:
158
172
  typer.echo(
@@ -221,6 +235,16 @@ def main(
221
235
  _java_min_depth = 8
222
236
  effective_depth = max(depth, _java_min_depth) if _is_java and depth < _java_min_depth else depth
223
237
 
238
+ # --agent: auto-select flags based on project characteristics
239
+ if agent:
240
+ compact = True
241
+ if _is_java:
242
+ no_tree = True
243
+ _agent_flags = ["--compact", "--dependencies", "--env-map", "--code-notes"]
244
+ if no_tree:
245
+ _agent_flags.append("--no-tree")
246
+ typer.echo(f"[agent] {' '.join(_agent_flags)}", err=True)
247
+
224
248
  scanner = FileScanner(target, max_depth=effective_depth)
225
249
  raw_tree = scanner.scan_tree()
226
250
 
@@ -594,7 +618,7 @@ def main(
594
618
 
595
619
  # 4. Serializar (con o sin modo compact)
596
620
  if compact:
597
- data = compact_view(sm)
621
+ data = compact_view(sm, no_tree=no_tree)
598
622
  # Aplicar redaccion sobre el dict del compact view
599
623
  if not no_redact:
600
624
  data = redact_dict(data)
@@ -602,6 +626,9 @@ def main(
602
626
  else:
603
627
  # Redactar sobre el dict serializado (SEC-01, SEC-03)
604
628
  raw_dict = asdict(sm)
629
+ if no_tree:
630
+ raw_dict.pop("file_tree", None)
631
+ raw_dict.pop("file_paths", None)
605
632
  if not no_redact:
606
633
  raw_dict = redact_dict(raw_dict)
607
634
 
@@ -631,31 +658,103 @@ def main(
631
658
 
632
659
  @app.command("prepare-context")
633
660
  def prepare_context_cmd(
634
- task: str = typer.Argument(..., help="Descripción de la tarea"),
635
- 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
+ ),
636
685
  ) -> None:
637
- """Prepara contexto mínimo optimizado para que un LLM modifique el código."""
638
- 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()
639
703
 
640
- 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)
641
711
 
642
- 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)
643
718
 
719
+ target = path.resolve()
644
720
  if not target.exists() or not target.is_dir():
645
721
  typer.echo(f"Error: '{target}' no es un directorio válido.", err=True)
646
722
  raise typer.Exit(code=1)
647
723
 
648
- builder = ContextBuilder(target)
649
- result = builder.prepare(task)
650
-
651
- out = {
652
- "task": result.task,
653
- "entry_points": result.entry_points,
654
- "relevant_files": [asdict(f) for f in result.relevant_files],
655
- "call_flow": result.call_flow,
656
- "snippets": [asdict(s) for s in result.snippets],
657
- "tests": result.tests,
658
- "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,
659
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)
660
759
 
661
760
  typer.echo(json.dumps(out, indent=2, ensure_ascii=False))
@@ -34,6 +34,7 @@ class DependencyAnalyzer:
34
34
  self._analyze_go,
35
35
  self._analyze_dotnet,
36
36
  self._analyze_java,
37
+ self._analyze_gradle,
37
38
  ):
38
39
  handler_records, handler_limitations = handler(root)
39
40
  records.extend(replace(record, workspace=workspace) for record in handler_records)
@@ -925,6 +926,47 @@ class DependencyAnalyzer:
925
926
  result.append(resolved)
926
927
  return self._dedupe(result)
927
928
 
929
+ def _parse_maven_properties(self, root_elem: ET.Element, ns: str) -> dict[str, str]:
930
+ properties: dict[str, str] = {}
931
+ props_elem = root_elem.find(f"{ns}properties")
932
+ if props_elem is not None:
933
+ for prop in props_elem:
934
+ tag = prop.tag.replace(ns, "") if ns else prop.tag
935
+ if prop.text:
936
+ properties[tag] = prop.text.strip()
937
+ return properties
938
+
939
+ def _resolve_maven_version(self, version_raw: Optional[str], properties: dict[str, str]) -> Optional[str]:
940
+ if not version_raw:
941
+ return None
942
+ if not version_raw.startswith("${"):
943
+ return version_raw
944
+ prop_name = version_raw[2:-1] if version_raw.endswith("}") else None
945
+ if prop_name and prop_name in properties:
946
+ return properties[prop_name]
947
+ return None
948
+
949
+ def _parse_dependency_management(
950
+ self, root_elem: ET.Element, ns: str, properties: dict[str, str]
951
+ ) -> dict[str, str]:
952
+ dm_versions: dict[str, str] = {}
953
+ dm_elem = root_elem.find(f"{ns}dependencyManagement")
954
+ if dm_elem is None:
955
+ return dm_versions
956
+ deps_elem = dm_elem.find(f"{ns}dependencies")
957
+ if deps_elem is None:
958
+ return dm_versions
959
+ for dep in deps_elem.findall(f"{ns}dependency"):
960
+ group_id = (dep.findtext(f"{ns}groupId") or "").strip()
961
+ artifact_id = (dep.findtext(f"{ns}artifactId") or "").strip()
962
+ if not group_id or not artifact_id:
963
+ continue
964
+ version_raw = (dep.findtext(f"{ns}version") or "").strip() or None
965
+ resolved = self._resolve_maven_version(version_raw, properties)
966
+ if resolved:
967
+ dm_versions[f"{group_id}:{artifact_id}"] = resolved
968
+ return dm_versions
969
+
928
970
  def _analyze_java(self, root: Path) -> tuple[list[DependencyRecord], list[str]]:
929
971
  pom = root / "pom.xml"
930
972
  if not pom.exists():
@@ -938,6 +980,9 @@ class DependencyAnalyzer:
938
980
  ns_match = re.match(r"\{[^}]+\}", root_elem.tag)
939
981
  ns = ns_match.group(0) if ns_match else ""
940
982
 
983
+ properties = self._parse_maven_properties(root_elem, ns)
984
+ dm_versions = self._parse_dependency_management(root_elem, ns, properties)
985
+
941
986
  records: list[DependencyRecord] = []
942
987
  deps_elem = root_elem.find(f"{ns}dependencies")
943
988
  if deps_elem is None:
@@ -949,8 +994,9 @@ class DependencyAnalyzer:
949
994
  if not group_id or not artifact_id:
950
995
  continue
951
996
  version_raw = (dep.findtext(f"{ns}version") or "").strip() or None
952
- # Versiones interpoladas con propiedades Maven (${...}) no son resolvibles estáticamente
953
- declared = version_raw if version_raw and not version_raw.startswith("${") else None
997
+ declared = self._resolve_maven_version(version_raw, properties)
998
+ if declared is None:
999
+ declared = dm_versions.get(f"{group_id}:{artifact_id}")
954
1000
  scope_text = (dep.findtext(f"{ns}scope") or "compile").strip().lower()
955
1001
  scope = "dev" if scope_text == "test" else "direct"
956
1002
  records.append(
@@ -969,6 +1015,115 @@ class DependencyAnalyzer:
969
1015
  limitations.append("java: pom.xml sin dependencias parseables (puede usar BOM o propiedades)")
970
1016
  return records, limitations
971
1017
 
1018
+ def _analyze_gradle(self, root: Path) -> tuple[list[DependencyRecord], list[str]]:
1019
+ for filename in ("build.gradle", "build.gradle.kts"):
1020
+ gradle_file = root / filename
1021
+ if gradle_file.exists():
1022
+ try:
1023
+ content = gradle_file.read_text(encoding="utf-8", errors="replace")
1024
+ except OSError:
1025
+ return [], [f"gradle: error al leer {filename}"]
1026
+ props = self._parse_gradle_properties(root, content)
1027
+ records = self._parse_gradle_dependencies(content, props, filename)
1028
+ return records, ["gradle: sin lockfile compatible; dependencias transitivas no disponibles"]
1029
+ return [], []
1030
+
1031
+ def _parse_gradle_properties(self, root: Path, content: str) -> dict[str, str]:
1032
+ props: dict[str, str] = {}
1033
+ # gradle.properties file (key=value format)
1034
+ gp = root / "gradle.properties"
1035
+ if gp.exists():
1036
+ try:
1037
+ for line in gp.read_text(encoding="utf-8", errors="replace").splitlines():
1038
+ stripped = line.strip()
1039
+ if stripped and not stripped.startswith("#") and "=" in stripped:
1040
+ k, _, v = stripped.partition("=")
1041
+ props[k.strip()] = v.strip()
1042
+ except OSError:
1043
+ pass
1044
+ # Variables declared in the build file itself.
1045
+ # Match: val/var/def x = "v", or bare x = "v" anywhere (ext blocks, top-level).
1046
+ # Negative lookbehind (?<![.\w]) prevents matching mid-expression (e.g. obj.field = "v").
1047
+ for m in re.finditer(r"""(?:(?:val|var|def)\s+)?(?<![.\w])(\w+)\s*=\s*["']([^"']+)["']""", content):
1048
+ props.setdefault(m.group(1), m.group(2))
1049
+ return props
1050
+
1051
+ def _resolve_gradle_version(self, version_raw: Optional[str], props: dict[str, str]) -> Optional[str]:
1052
+ if not version_raw:
1053
+ return None
1054
+ # ${varName} — Groovy string interpolation
1055
+ m = re.fullmatch(r"\$\{(\w+)\}", version_raw)
1056
+ if m:
1057
+ return props.get(m.group(1))
1058
+ # $varName — Kotlin string interpolation
1059
+ m = re.fullmatch(r"\$(\w+)", version_raw)
1060
+ if m:
1061
+ return props.get(m.group(1))
1062
+ return version_raw
1063
+
1064
+ def _parse_gradle_dependencies(
1065
+ self, content: str, props: dict[str, str], manifest_path: str
1066
+ ) -> list[DependencyRecord]:
1067
+ _DIRECT = frozenset({
1068
+ "implementation", "api", "compileOnly", "runtimeOnly", "compile", "provided",
1069
+ "compileClasspath", "runtimeClasspath",
1070
+ })
1071
+ _DEV = frozenset({
1072
+ "testImplementation", "testRuntimeOnly", "testCompileOnly", "testApi",
1073
+ "testCompile", "androidTestImplementation", "annotationProcessor", "kapt",
1074
+ "debugImplementation", "releaseImplementation",
1075
+ })
1076
+ all_scopes = _DIRECT | _DEV
1077
+ records: list[DependencyRecord] = []
1078
+
1079
+ # String notation: scope("group:artifact") or scope("group:artifact:version")
1080
+ str_pat = re.compile(
1081
+ r"""(\w+)\s*\(?\s*["']([A-Za-z][\w.\-]*:[A-Za-z][\w.\-]*)(?::([^"'\s)]+))?["']"""
1082
+ )
1083
+ for m in str_pat.finditer(content):
1084
+ scope_kw = m.group(1)
1085
+ if scope_kw not in all_scopes:
1086
+ continue
1087
+ parts = m.group(2).split(":")
1088
+ if len(parts) < 2:
1089
+ continue
1090
+ group_id, artifact_id = parts[0].strip(), parts[1].strip()
1091
+ if not group_id or not artifact_id:
1092
+ continue
1093
+ version = self._resolve_gradle_version(m.group(3), props)
1094
+ records.append(DependencyRecord(
1095
+ name=f"{group_id}:{artifact_id}",
1096
+ ecosystem="java",
1097
+ scope="dev" if scope_kw in _DEV else "direct",
1098
+ declared_version=version,
1099
+ source="manifest",
1100
+ manifest_path=manifest_path,
1101
+ ))
1102
+
1103
+ # Map notation: scope(group: "g", name: "a", version: "v") — Groovy uses ':', Kotlin uses '='
1104
+ map_pat = re.compile(
1105
+ r"""(\w+)\s*\(?\s*group\s*[=:]\s*["']([^"']+)["']\s*,\s*name\s*[=:]\s*["']([^"']+)["']"""
1106
+ r"""(?:\s*,\s*version\s*[=:]\s*["']([^"']+)["'])?"""
1107
+ )
1108
+ for m in map_pat.finditer(content):
1109
+ scope_kw = m.group(1)
1110
+ if scope_kw not in all_scopes:
1111
+ continue
1112
+ group_id, artifact_id = m.group(2).strip(), m.group(3).strip()
1113
+ if not group_id or not artifact_id:
1114
+ continue
1115
+ version = self._resolve_gradle_version(m.group(4), props)
1116
+ records.append(DependencyRecord(
1117
+ name=f"{group_id}:{artifact_id}",
1118
+ ecosystem="java",
1119
+ scope="dev" if scope_kw in _DEV else "direct",
1120
+ declared_version=version,
1121
+ source="manifest",
1122
+ manifest_path=manifest_path,
1123
+ ))
1124
+
1125
+ return self._dedupe(records)
1126
+
972
1127
  def _load_yaml_file(self, path: Path) -> Optional[dict[str, Any]]:
973
1128
  if not path.exists():
974
1129
  return None
@@ -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
sourcecode/serializer.py CHANGED
@@ -54,27 +54,31 @@ def to_yaml(sm: SourceMap) -> str:
54
54
  return stream.getvalue()
55
55
 
56
56
 
57
- def compact_view(sm: SourceMap) -> dict[str, Any]:
57
+ def compact_view(sm: SourceMap, *, no_tree: bool = False) -> dict[str, Any]:
58
58
  """Proyeccion compacta del SourceMap (~500-700 tokens).
59
59
 
60
60
  Incluye: schema_version, project_type, stacks, entry_points,
61
61
  project_summary (siempre), architecture_summary (siempre),
62
- dependency_summary (cuando requested=True),
63
- file_tree_depth1 (backward compat).
62
+ dependency_summary + key_dependencies (cuando requested=True),
63
+ file_tree_depth1 (omitido si no_tree=True).
64
64
 
65
65
  Excluye: dependencies (lista larga), docs, module_graph.
66
66
  """
67
- depth1: dict[str, Any] = {}
68
- for name, value in sm.file_tree.items():
69
- if isinstance(value, dict):
70
- depth1[name] = {}
71
- else:
72
- depth1[name] = None
67
+ depth1: dict[str, Any] | None = None
68
+ if not no_tree:
69
+ depth1 = {}
70
+ for name, value in sm.file_tree.items():
71
+ if isinstance(value, dict):
72
+ depth1[name] = {}
73
+ else:
74
+ depth1[name] = None
73
75
 
74
76
  dep_summary_dict: Any = None
77
+ key_deps: Any = None
75
78
  if sm.dependency_summary is not None and sm.dependency_summary.requested:
76
79
  dep_summary_dict = asdict(sm.dependency_summary)
77
80
  dep_summary_dict.pop("dependencies", None)
81
+ key_deps = [asdict(d) for d in sm.key_dependencies]
78
82
 
79
83
  env_summary_dict: Any = None
80
84
  if sm.env_summary is not None and sm.env_summary.requested:
@@ -84,18 +88,21 @@ def compact_view(sm: SourceMap) -> dict[str, Any]:
84
88
  if sm.code_notes_summary is not None and sm.code_notes_summary.requested:
85
89
  code_notes_summary_dict = asdict(sm.code_notes_summary)
86
90
 
87
- return {
91
+ result: dict[str, Any] = {
88
92
  "schema_version": sm.metadata.schema_version,
89
93
  "project_type": sm.project_type,
90
94
  "project_summary": sm.project_summary,
91
95
  "architecture_summary": sm.architecture_summary,
92
96
  "stacks": [asdict(stack) for stack in sm.stacks],
93
97
  "entry_points": [asdict(entry_point) for entry_point in sm.entry_points],
94
- "file_tree_depth1": depth1,
95
98
  "dependency_summary": dep_summary_dict,
99
+ "key_dependencies": key_deps,
96
100
  "env_summary": env_summary_dict,
97
101
  "code_notes_summary": code_notes_summary_dict,
98
102
  }
103
+ if not no_tree:
104
+ result["file_tree_depth1"] = depth1
105
+ return result
99
106
 
100
107
 
101
108
  def normalize_source_map(sm: SourceMap) -> SourceMap:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sourcecode
3
- Version: 0.19.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
@@ -81,7 +81,7 @@ sourcecode --version
81
81
 
82
82
  ## What It Detects
83
83
 
84
- - Stacks: Node.js, Python, Go, Rust, Java, PHP, Ruby, and Dart.
84
+ - Stacks: Node.js, Python, Go, Rust, Java (Maven `pom.xml` and Gradle `build.gradle`/`build.gradle.kts`), PHP, Ruby, and Dart.
85
85
  - Frameworks associated with each stack when enough signals are present.
86
86
  - `project_type`: `webapp`, `api`, `library`, `cli`, `fullstack`, `monorepo`, or `unknown`.
87
87
  - Relevant `entry_points`, such as `main.py`, `cmd/api/main.go`, or `app/page.tsx`.
@@ -111,6 +111,8 @@ sourcecode --version
111
111
  | `--env-map` | off | Map all environment variables referenced in source code: key name, required/optional status, inferred type (`string`, `int`, `bool`, `url`, `path`, `enum`), functional category (`database`, `auth`, `cache`, `storage`, `service`, `observability`, `feature_flag`, `server`, `general`), default value when present, and source file locations. Supplements with descriptions from `.env.example`, `.env.sample`, and similar reference files. |
112
112
  | `--code-notes` | off | Extract inline code annotations — `TODO`, `FIXME`, `HACK`, `NOTE`, `DEPRECATED`, `WARNING`, `XXX`, `BUG`, `OPTIMIZE` — with file path, line number, annotation text, and the nearest enclosing function or class. Also detects Architecture Decision Records (ADRs) in `docs/decisions/`, `docs/adr/`, `adr/`, and similar directories, extracting title, status, and summary. |
113
113
  | `--depth INTEGER` | `4` | Maximum file tree depth. Range: 1–20. |
114
+ | `--no-tree` | off | Suppress `file_tree`, `file_paths` (and `file_tree_depth1` in `--compact`) from the output. Ideal with `--dependencies` on large projects to get dependency data without a multi-thousand-line file tree. |
115
+ | `--agent` | off | Agent mode: auto-selects `--compact --dependencies --env-map --code-notes` and adds `--no-tree` automatically for Java/Gradle projects or large repositories. Prints selected flags to stderr. Can be combined with other flags (e.g. `--agent --git-context`). |
114
116
  | `--no-redact` | off | Disable secret redaction (enabled by default). |
115
117
  | `--version` | — | Show version and exit. |
116
118
 
@@ -123,8 +125,8 @@ The full schema (`SourceMap`) includes the following fields:
123
125
  | Field | Type | Description |
124
126
  |-------|------|-------------|
125
127
  | `metadata` | object | Schema version, timestamp, `sourcecode` version, and analyzed path. |
126
- | `file_tree` | object | Repository tree where `null` represents a file and `{}` represents a directory. |
127
- | `file_paths` | array | Flat list of all project paths derived from `file_tree`, with forward-slash separators. Always present; respects `--depth`. |
128
+ | `file_tree` | object | Repository tree where `null` represents a file and `{}` represents a directory. Suppressed when `--no-tree` is active. |
129
+ | `file_paths` | array | Flat list of all project paths derived from `file_tree`, with forward-slash separators. Respects `--depth`. Suppressed when `--no-tree` is active. |
128
130
  | `project_summary` | string\|null | Deterministic natural-language description of the project. Includes: manifest/README description when available, detected architecture pattern (`layered`, `mvc`, `hexagonal`, `fullstack`), business domain names inferred from directory structure, entry points (when no domains are detected), and dependency count. Present when stacks are detected. |
129
131
  | `architecture_summary` | string\|null | Static summary of the main execution flow, orchestrated modules, and output produced by the project. Present when enough structural evidence is available. |
130
132
  | `stacks` | array | Stack detections with confidence, frameworks, manifests, `primary`, `root`, `workspace`, and `signals`. |
@@ -223,7 +225,7 @@ ADR detection looks for Markdown files in `docs/decisions/`, `docs/adr/`, `adr/`
223
225
 
224
226
  ## Compact Mode
225
227
 
226
- `--compact` returns a reduced JSON view optimized for LLM prompts (~500-700 tokens). It excludes the full `dependencies` list, `docs`, and `module_graph`, while retaining the fields most useful for project orientation. When optional flags are also active, their summaries are included: `dependency_summary` (with `--dependencies`), `env_summary` (with `--env-map`), and `code_notes_summary` (with `--code-notes`).
228
+ `--compact` returns a reduced JSON view optimized for LLM prompts. It excludes the full `dependencies` list, `docs`, and `module_graph`, while retaining the fields most useful for project orientation. When optional flags are also active, their summaries are included: `dependency_summary` + `key_dependencies` (with `--dependencies`), `env_summary` (with `--env-map`), and `code_notes_summary` (with `--code-notes`). Adding `--no-tree` further removes `file_tree_depth1`, bringing the output to ~500-800 tokens depending on how many flags are active.
227
229
 
228
230
  Real output from a Python FastAPI project:
229
231
 
@@ -266,7 +268,7 @@ Real output from a Python FastAPI project:
266
268
  }
267
269
  ```
268
270
 
269
- When `--compact --dependencies` is used, `dependency_summary` is populated instead of `null`.
271
+ When `--compact --dependencies` is used, `dependency_summary` is populated and `key_dependencies` lists the top-15 direct dependencies with resolved versions. Add `--no-tree` to drop `file_tree_depth1` for the smallest possible context footprint.
270
272
 
271
273
  ## Docs Mode
272
274
 
@@ -444,15 +446,27 @@ In a monorepo, each stack includes its own `root` and `workspace`, and one of th
444
446
 
445
447
  Different modes optimize for different tradeoffs between context size and depth of information.
446
448
 
447
- **`--compact` — minimal context (~500-700 tokens)**
449
+ **`--agent` — zero-config mode for AI pipelines**
450
+
451
+ Best for: running `sourcecode` from inside an agent without knowing the project in advance. Auto-selects the right flag combination based on detected stack and project size.
452
+
453
+ ```bash
454
+ sourcecode --agent .
455
+ sourcecode --agent --git-context . # add git context on top of auto-selected flags
456
+ ```
457
+
458
+ On a Java/Gradle project the output is: `--compact --dependencies --env-map --code-notes --no-tree` (~800 tokens). On a Node/Python project: same without `--no-tree` (~900 tokens including depth-1 tree). Selected flags are always printed to stderr so the agent can log them.
459
+
460
+ **`--compact` — minimal context**
448
461
 
449
462
  Best for: initial orientation, deciding what to explore next, fast handoffs between agents.
450
463
 
451
- Includes: `project_summary` (instant project description), `architecture_summary` (execution-oriented static summary), `stacks`, `entry_points`, `file_tree_depth1`, and `dependency_summary` when `--dependencies` was also requested.
464
+ Includes: `project_summary`, `architecture_summary`, `stacks`, `entry_points`, `file_tree_depth1`, and — when the respective flags are active — `dependency_summary` + `key_dependencies`, `env_summary`, `code_notes_summary`.
452
465
 
453
466
  ```bash
454
467
  sourcecode --compact .
455
- sourcecode --compact --dependencies .
468
+ sourcecode --compact --dependencies . # + key dependencies with versions
469
+ sourcecode --compact --dependencies --no-tree . # smallest footprint: no file tree
456
470
  ```
457
471
 
458
472
  **Full output — deep analysis**
@@ -622,6 +636,75 @@ Combine flags for a comprehensive project handoff:
622
636
  sourcecode --compact --env-map --code-notes --git-context .
623
637
  ```
624
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
+
625
708
  ## Development
626
709
 
627
710
  Editable install with development dependencies:
@@ -1,22 +1,22 @@
1
- sourcecode/__init__.py,sha256=K3oF2tC36Se4IiYU6MvNhrg4EoK_jaLeik_nUWPID4Y,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=pB0NmyaH-gCrHCFlNxYSMfuArM6nitv6PyrMLkYKKRs,25429
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
- sourcecode/dependency_analyzer.py,sha256=KITti0XLHK3RvZGqAUseWnlFLXGFyv5FqvSK5R6PB1g,41147
8
+ sourcecode/dependency_analyzer.py,sha256=2TYBIpenhfWf_TVBZZfq3T0SHFv8xCKFIyn1XnFHG6s,48146
9
9
  sourcecode/doc_analyzer.py,sha256=Ec3orx6vBKsh5cNM3-F4y2Got2KuKx8w3dErwtdtM-A,19891
10
10
  sourcecode/env_analyzer.py,sha256=JZxBOuIxnM0xw0IaJFL6LiPJBErL848L3XoTOHGBqZI,13554
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
19
- sourcecode/serializer.py,sha256=edOfD25lUKkZ0gIjFcjqWmY4yHSnOKvudzNisvW_RYg,13456
18
+ sourcecode/semantic_analyzer.py,sha256=asQfJf-EhzYaOTA-iMuZsrVXtbW7SV2WEKCxgsxa88Y,79413
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
22
22
  sourcecode/workspace.py,sha256=fQlVoNx8S-fSHpKoJ0JBvEHCFkxszH0KZVJed1i3TRk,6845
@@ -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.19.0.dist-info/METADATA,sha256=cv1q6sPPTkTruEdpHr8JioUXmXuSec5dPAhAw9NXoW8,25900
43
- sourcecode-0.19.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
44
- sourcecode-0.19.0.dist-info/entry_points.txt,sha256=voDtdlGU2-jR3wCZpQoOls_0eUKaDVCKIfajGHFDATs,50
45
- sourcecode-0.19.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,,