sourcecode 0.21.0__tar.gz → 0.22.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. {sourcecode-0.21.0 → sourcecode-0.22.0}/PKG-INFO +1 -1
  2. {sourcecode-0.21.0 → sourcecode-0.22.0}/docs/schema.md +2 -2
  3. {sourcecode-0.21.0 → sourcecode-0.22.0}/pyproject.toml +1 -1
  4. {sourcecode-0.21.0 → sourcecode-0.22.0}/src/sourcecode/__init__.py +1 -1
  5. {sourcecode-0.21.0 → sourcecode-0.22.0}/src/sourcecode/architecture_summary.py +72 -23
  6. {sourcecode-0.21.0 → sourcecode-0.22.0}/src/sourcecode/classifier.py +3 -1
  7. {sourcecode-0.21.0 → sourcecode-0.22.0}/src/sourcecode/cli.py +30 -24
  8. {sourcecode-0.21.0 → sourcecode-0.22.0}/src/sourcecode/dependency_analyzer.py +75 -0
  9. {sourcecode-0.21.0 → sourcecode-0.22.0}/src/sourcecode/detectors/base.py +2 -0
  10. {sourcecode-0.21.0 → sourcecode-0.22.0}/src/sourcecode/detectors/heuristic.py +2 -0
  11. {sourcecode-0.21.0 → sourcecode-0.22.0}/src/sourcecode/detectors/nodejs.py +6 -1
  12. {sourcecode-0.21.0 → sourcecode-0.22.0}/src/sourcecode/detectors/project.py +10 -1
  13. {sourcecode-0.21.0 → sourcecode-0.22.0}/src/sourcecode/detectors/python.py +63 -14
  14. {sourcecode-0.21.0 → sourcecode-0.22.0}/src/sourcecode/scanner.py +58 -2
  15. {sourcecode-0.21.0 → sourcecode-0.22.0}/src/sourcecode/schema.py +1 -0
  16. {sourcecode-0.21.0 → sourcecode-0.22.0}/src/sourcecode/serializer.py +187 -0
  17. {sourcecode-0.21.0 → sourcecode-0.22.0}/src/sourcecode/summarizer.py +25 -10
  18. {sourcecode-0.21.0 → sourcecode-0.22.0}/src/sourcecode/tree_utils.py +14 -0
  19. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/test_architecture_summary.py +2 -2
  20. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/test_dependency_analyzer_node_python.py +2 -2
  21. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/test_integration.py +12 -5
  22. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/test_integration_dependencies.py +5 -5
  23. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/test_integration_docs.py +2 -2
  24. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/test_integration_graph_modules.py +2 -6
  25. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/test_integration_lqn.py +6 -8
  26. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/test_integration_metrics.py +7 -7
  27. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/test_real_projects.py +1 -1
  28. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/test_summarizer.py +1 -2
  29. {sourcecode-0.21.0 → sourcecode-0.22.0}/.gitignore +0 -0
  30. {sourcecode-0.21.0 → sourcecode-0.22.0}/.ruff.toml +0 -0
  31. {sourcecode-0.21.0 → sourcecode-0.22.0}/README.md +0 -0
  32. {sourcecode-0.21.0 → sourcecode-0.22.0}/src/sourcecode/architecture_analyzer.py +0 -0
  33. {sourcecode-0.21.0 → sourcecode-0.22.0}/src/sourcecode/code_notes_analyzer.py +0 -0
  34. {sourcecode-0.21.0 → sourcecode-0.22.0}/src/sourcecode/coverage_parser.py +0 -0
  35. {sourcecode-0.21.0 → sourcecode-0.22.0}/src/sourcecode/detectors/__init__.py +0 -0
  36. {sourcecode-0.21.0 → sourcecode-0.22.0}/src/sourcecode/detectors/dart.py +0 -0
  37. {sourcecode-0.21.0 → sourcecode-0.22.0}/src/sourcecode/detectors/dotnet.py +0 -0
  38. {sourcecode-0.21.0 → sourcecode-0.22.0}/src/sourcecode/detectors/elixir.py +0 -0
  39. {sourcecode-0.21.0 → sourcecode-0.22.0}/src/sourcecode/detectors/go.py +0 -0
  40. {sourcecode-0.21.0 → sourcecode-0.22.0}/src/sourcecode/detectors/java.py +0 -0
  41. {sourcecode-0.21.0 → sourcecode-0.22.0}/src/sourcecode/detectors/jvm_ext.py +0 -0
  42. {sourcecode-0.21.0 → sourcecode-0.22.0}/src/sourcecode/detectors/parsers.py +0 -0
  43. {sourcecode-0.21.0 → sourcecode-0.22.0}/src/sourcecode/detectors/php.py +0 -0
  44. {sourcecode-0.21.0 → sourcecode-0.22.0}/src/sourcecode/detectors/ruby.py +0 -0
  45. {sourcecode-0.21.0 → sourcecode-0.22.0}/src/sourcecode/detectors/rust.py +0 -0
  46. {sourcecode-0.21.0 → sourcecode-0.22.0}/src/sourcecode/detectors/systems.py +0 -0
  47. {sourcecode-0.21.0 → sourcecode-0.22.0}/src/sourcecode/detectors/terraform.py +0 -0
  48. {sourcecode-0.21.0 → sourcecode-0.22.0}/src/sourcecode/detectors/tooling.py +0 -0
  49. {sourcecode-0.21.0 → sourcecode-0.22.0}/src/sourcecode/doc_analyzer.py +0 -0
  50. {sourcecode-0.21.0 → sourcecode-0.22.0}/src/sourcecode/env_analyzer.py +0 -0
  51. {sourcecode-0.21.0 → sourcecode-0.22.0}/src/sourcecode/git_analyzer.py +0 -0
  52. {sourcecode-0.21.0 → sourcecode-0.22.0}/src/sourcecode/graph_analyzer.py +0 -0
  53. {sourcecode-0.21.0 → sourcecode-0.22.0}/src/sourcecode/metrics_analyzer.py +0 -0
  54. {sourcecode-0.21.0 → sourcecode-0.22.0}/src/sourcecode/prepare_context.py +0 -0
  55. {sourcecode-0.21.0 → sourcecode-0.22.0}/src/sourcecode/redactor.py +0 -0
  56. {sourcecode-0.21.0 → sourcecode-0.22.0}/src/sourcecode/semantic_analyzer.py +0 -0
  57. {sourcecode-0.21.0 → sourcecode-0.22.0}/src/sourcecode/workspace.py +0 -0
  58. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/__init__.py +0 -0
  59. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/conftest.py +0 -0
  60. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/fixtures/coverage.xml +0 -0
  61. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/fixtures/fastapi_app/pyproject.toml +0 -0
  62. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/fixtures/fastapi_app/src/main.py +0 -0
  63. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/fixtures/go_service/cmd/api/main.go +0 -0
  64. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/fixtures/go_service/go.mod +0 -0
  65. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/fixtures/jacoco.xml +0 -0
  66. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/fixtures/lcov.info +0 -0
  67. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/fixtures/nextjs_app/app/page.tsx +0 -0
  68. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/fixtures/nextjs_app/package.json +0 -0
  69. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/fixtures/nextjs_app/pnpm-lock.yaml +0 -0
  70. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/fixtures/pnpm_monorepo/apps/web/app/page.tsx +0 -0
  71. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/fixtures/pnpm_monorepo/apps/web/package.json +0 -0
  72. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/fixtures/pnpm_monorepo/packages/api/main.py +0 -0
  73. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/fixtures/pnpm_monorepo/packages/api/pyproject.toml +0 -0
  74. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/fixtures/pnpm_monorepo/pnpm-workspace.yaml +0 -0
  75. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/test_architecture_analyzer.py +0 -0
  76. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/test_classifier.py +0 -0
  77. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/test_cli.py +0 -0
  78. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/test_code_notes_analyzer.py +0 -0
  79. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/test_coverage_parser.py +0 -0
  80. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/test_cross_consistency.py +0 -0
  81. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/test_dependency_analyzer_polyglot.py +0 -0
  82. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/test_dependency_schema.py +0 -0
  83. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/test_detector_go_rust_java.py +0 -0
  84. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/test_detector_nodejs.py +0 -0
  85. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/test_detector_php_ruby_dart.py +0 -0
  86. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/test_detector_python.py +0 -0
  87. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/test_detector_universal_managed.py +0 -0
  88. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/test_detector_universal_systems.py +0 -0
  89. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/test_detectors_base.py +0 -0
  90. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/test_doc_analyzer_jsdom.py +0 -0
  91. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/test_doc_analyzer_python.py +0 -0
  92. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/test_graph_analyzer_polyglot.py +0 -0
  93. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/test_graph_analyzer_python_node.py +0 -0
  94. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/test_graph_schema.py +0 -0
  95. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/test_integration_detection.py +0 -0
  96. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/test_integration_multistack.py +0 -0
  97. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/test_integration_semantics.py +0 -0
  98. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/test_integration_universal.py +0 -0
  99. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/test_metrics_analyzer.py +0 -0
  100. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/test_packaging.py +0 -0
  101. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/test_redactor.py +0 -0
  102. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/test_scanner.py +0 -0
  103. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/test_schema.py +0 -0
  104. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/test_schema_normalization.py +0 -0
  105. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/test_semantic_analyzer_node.py +0 -0
  106. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/test_semantic_analyzer_python.py +0 -0
  107. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/test_semantic_import_resolution.py +0 -0
  108. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/test_semantic_schema.py +0 -0
  109. {sourcecode-0.21.0 → sourcecode-0.22.0}/tests/test_workspace_analyzer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sourcecode
3
- Version: 0.21.0
3
+ Version: 0.22.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 @@ Campos:
81
81
  {
82
82
  "schema_version": "1.0",
83
83
  "generated_at": "2026-04-07T19:41:05.686277+00:00",
84
- "sourcecode_version": "0.21.0",
84
+ "sourcecode_version": "0.22.0",
85
85
  "analyzed_path": "/abs/path/to/project"
86
86
  }
87
87
  ```
@@ -645,7 +645,7 @@ Ejemplo de salida para un monorepo con web Node.js y API Python con `--dependenc
645
645
  "metadata": {
646
646
  "schema_version": "1.0",
647
647
  "generated_at": "2026-04-07T19:41:05.686277+00:00",
648
- "sourcecode_version": "0.21.0",
648
+ "sourcecode_version": "0.22.0",
649
649
  "analyzed_path": "/abs/path/to/project"
650
650
  },
651
651
  "file_tree": {
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sourcecode"
7
- version = "0.21.0"
7
+ version = "0.22.0"
8
8
  description = "Genera un mapa de contexto estructurado de proyectos de software para agentes IA"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -1,3 +1,3 @@
1
1
  """sourcecode — Genera mapas de contexto estructurado para agentes IA."""
2
2
 
3
- __version__ = "0.21.0"
3
+ __version__ = "0.22.0"
@@ -14,6 +14,20 @@ _NODE_EXTENSIONS = {".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"}
14
14
  _GO_EXTENSIONS = {".go"}
15
15
  _JAVA_EXTENSIONS = {".java", ".kt", ".scala"}
16
16
 
17
+ _CORE_DETECTION_MODULES = {"scanner", "detectors", "classifier", "workspace"}
18
+
19
+ _OPTIONAL_LABEL_MAP: dict[str, str] = {
20
+ "DependencyAnalyzer": "dependencias",
21
+ "GraphAnalyzer": "grafo de módulos",
22
+ "DocAnalyzer": "docs y docstrings",
23
+ "MetricsAnalyzer": "métricas de calidad",
24
+ "SemanticAnalyzer": "semántica y call graph",
25
+ "ArchitectureAnalyzer": "inferencia arquitectónica",
26
+ "GitAnalyzer": "contexto git",
27
+ "EnvAnalyzer": "variables de entorno",
28
+ "CodeNotesAnalyzer": "anotaciones de código",
29
+ }
30
+
17
31
 
18
32
  class ArchitectureSummarizer:
19
33
  """Construye un resumen arquitectonico estatico de 3-5 lineas."""
@@ -51,18 +65,25 @@ class ArchitectureSummarizer:
51
65
  if content is None:
52
66
  return f"Entry point principal: {entry_point.path}. Arquitectura no inferida con suficiente evidencia estatica."
53
67
 
54
- lines = [self._describe_entry_point(entry_point, sm.project_type)]
55
68
  suffix = Path(entry_point.path).suffix
56
69
  if suffix in _PYTHON_EXTENSIONS:
57
- lines.extend(self._summarize_python_entry(entry_point.path, content))
70
+ lang_lines = self._summarize_python_entry(entry_point.path, content)
58
71
  elif suffix in _NODE_EXTENSIONS:
59
- lines.extend(self._summarize_node_entry(entry_point.path, content))
72
+ lang_lines = self._summarize_node_entry(entry_point.path, content)
60
73
  elif suffix in _GO_EXTENSIONS:
61
- lines.extend(self._summarize_go_entry(entry_point.path, content))
74
+ lang_lines = self._summarize_go_entry(entry_point.path, content)
62
75
  elif suffix in _JAVA_EXTENSIONS:
63
- lines.extend(self._summarize_java_entry(entry_point.path, content, sm.stacks))
76
+ lang_lines = self._summarize_java_entry(entry_point.path, content, sm.stacks)
64
77
  else:
65
- lines.append("Orquesta modulos internos no detallados por el analisis estatico disponible.")
78
+ lang_lines = []
79
+
80
+ if lang_lines:
81
+ # Product-level description available — no need for internal "Entry point: ..." header
82
+ lines = lang_lines
83
+ else:
84
+ lines = [self._describe_entry_point(entry_point, sm.project_type)]
85
+ if not lang_lines:
86
+ lines.append("Orquesta modulos internos no detallados por el analisis estatico disponible.")
66
87
 
67
88
  unique_lines: list[str] = []
68
89
  seen: set[str] = set()
@@ -78,11 +99,11 @@ class ArchitectureSummarizer:
78
99
  try:
79
100
  tree = ast.parse(content)
80
101
  except SyntaxError:
81
- return ["Orquesta modulos Python, pero el entry point no pudo parsearse por completo."]
102
+ return []
82
103
 
83
104
  package_prefix = self._python_package_prefix(path)
84
105
  imported_modules: list[str] = []
85
- optional_analyzers: list[str] = []
106
+ optional_class_names: list[str] = []
86
107
  uses_serializer = False
87
108
  uses_redactor = False
88
109
 
@@ -90,30 +111,58 @@ class ArchitectureSummarizer:
90
111
  if isinstance(node, ast.ImportFrom) and isinstance(node.module, str):
91
112
  if package_prefix and node.module.startswith(package_prefix):
92
113
  imported_modules.append(node.module.rsplit(".", 1)[-1])
93
- if node.module == "sourcecode.serializer":
114
+ if node.module.endswith(".serializer") or "serializer" in node.module:
94
115
  uses_serializer = True
95
- if node.module == "sourcecode.redactor":
116
+ if node.module.endswith(".redactor") or "redactor" in node.module:
96
117
  uses_redactor = True
97
118
  elif isinstance(node, ast.Assign):
98
- optional = self._extract_optional_analyzer(node)
99
- if optional:
100
- optional_analyzers.append(optional)
119
+ cls_name = self._extract_optional_analyzer_class_name(node)
120
+ if cls_name:
121
+ optional_class_names.append(cls_name)
101
122
 
123
+ module_set = set(imported_modules)
102
124
  lines: list[str] = []
103
- core_modules = self._format_module_list(imported_modules)
104
- if core_modules:
105
- lines.append(f"Orquesta modulos internos: {core_modules}.")
106
- if optional_analyzers:
107
- lines.append(f"Activa analisis opcionales: {', '.join(optional_analyzers)}.")
125
+
126
+ # Detection line — infer from core modules present
127
+ has_core = bool(module_set & _CORE_DETECTION_MODULES)
128
+ if has_core:
129
+ lines.append("Analiza el árbol del repositorio y detecta stack, entrypoints y tipo de proyecto.")
130
+ elif module_set:
131
+ lines.append("Analiza el proyecto y produce información estructurada.")
132
+
133
+ # Output line
108
134
  if uses_serializer:
109
- output_line = "Produce un SourceMap serializado en JSON/YAML y una vista compacta."
135
+ out = "Produce un SourceMap serializable en JSON/YAML"
110
136
  if uses_redactor:
111
- output_line = "Produce un SourceMap serializado en JSON/YAML y una vista compacta con redaccion de secretos."
112
- lines.append(output_line)
113
- elif not lines:
114
- lines.append("Orquesta modulos Python internos sin un patron de salida claramente inferible.")
137
+ out += " con redacción de secretos"
138
+ lines.append(out + ".")
139
+
140
+ # Optional capabilities line
141
+ opt_labels = [
142
+ _OPTIONAL_LABEL_MAP[cls]
143
+ for cls in optional_class_names
144
+ if cls in _OPTIONAL_LABEL_MAP
145
+ ]
146
+ if opt_labels:
147
+ if len(opt_labels) > 1:
148
+ joined = ", ".join(opt_labels[:-1]) + " y " + opt_labels[-1]
149
+ else:
150
+ joined = opt_labels[0]
151
+ lines.append(f"Opcionalmente añade {joined}.")
152
+
115
153
  return lines
116
154
 
155
+ def _extract_optional_analyzer_class_name(self, node: ast.Assign) -> str | None:
156
+ value = node.value
157
+ if not isinstance(value, ast.IfExp):
158
+ return None
159
+ if not isinstance(value.body, ast.Call):
160
+ return None
161
+ if not isinstance(value.body.func, ast.Name):
162
+ return None
163
+ cls_name = value.body.func.id
164
+ return cls_name if cls_name.endswith("Analyzer") else None
165
+
117
166
  def _summarize_node_entry(self, path: str, content: str) -> list[str]:
118
167
  imports = re.findall(r"""from\s+['"](\.?\.?/[^'"]+)['"]|require\(['"](\.?\.?/[^'"]+)['"]\)""", content)
119
168
  modules = [item for pair in imports for item in pair if item]
@@ -128,7 +128,9 @@ class TypeClassifier:
128
128
  has_api = False
129
129
  for stack in stacks:
130
130
  frameworks = {framework.name for framework in stack.frameworks}
131
- if frameworks & _WEB_FRAMEWORKS or stack.stack == "nodejs":
131
+ if frameworks & _WEB_FRAMEWORKS:
132
+ has_web = True
133
+ elif stack.stack == "nodejs" and stack.detection_method != "heuristic":
132
134
  has_web = True
133
135
  if frameworks & _API_FRAMEWORKS or stack.stack in _API_STACKS:
134
136
  has_api = True
@@ -78,7 +78,12 @@ def main(
78
78
  no_tree: bool = typer.Option(
79
79
  False,
80
80
  "--no-tree",
81
- help="Suprimir file_tree y file_paths del output (ideal con --dependencies en proyectos grandes)",
81
+ help="Suprimir file_tree y file_paths del output (ahora deprecado: el arbol ya no se incluye por defecto)",
82
+ ),
83
+ tree: bool = typer.Option(
84
+ False,
85
+ "--tree",
86
+ help="Incluir file_tree completo y file_paths en el output (capa deep-dive)",
82
87
  ),
83
88
  no_redact: bool = typer.Option(
84
89
  False,
@@ -114,7 +119,7 @@ def main(
114
119
  full_metrics: bool = typer.Option(
115
120
  False,
116
121
  "--full-metrics",
117
- help="Incluir metricas de calidad: LOC, simbolos, complejidad, tests y cobertura por fichero",
122
+ help="Auditoria tecnica: LOC, simbolos, complejidad ciclomatica y cobertura por fichero. No incluido en --agent (uso: CI, code review, no context principal para agentes IA)",
118
123
  ),
119
124
  semantics: bool = typer.Option(
120
125
  False,
@@ -159,7 +164,7 @@ def main(
159
164
  agent: bool = typer.Option(
160
165
  False,
161
166
  "--agent",
162
- help="Modo agente: selecciona automaticamente --compact --dependencies --env-map --code-notes y activa --no-tree en proyectos Java/Gradle o grandes",
167
+ help="Modo agente: output estructurado y sin ruido para consumo por IA. Incluye identidad, entrypoints, arquitectura, dependencias clave, señales operacionales y gaps. Sin arbol de ficheros ni secciones vacias.",
163
168
  ),
164
169
  ) -> None:
165
170
  """Genera un mapa de contexto estructurado del proyecto en formato JSON o YAML."""
@@ -216,7 +221,7 @@ def main(
216
221
  SourceMap,
217
222
  StackDetection,
218
223
  )
219
- from sourcecode.serializer import compact_view, normalize_source_map, validate_cross_analyzer_consistency, validate_source_map, write_output
224
+ from sourcecode.serializer import agent_view, compact_view, normalize_source_map, standard_view, validate_cross_analyzer_consistency, validate_source_map, write_output
220
225
  from sourcecode.workspace import WorkspaceAnalyzer
221
226
 
222
227
  # 1. Escanear el directorio (SCAN-01 a SCAN-05)
@@ -235,15 +240,13 @@ def main(
235
240
  _java_min_depth = 8
236
241
  effective_depth = max(depth, _java_min_depth) if _is_java and depth < _java_min_depth else depth
237
242
 
238
- # --agent: auto-select flags based on project characteristics
243
+ # --agent: enable signal analyzers; output via agent_view (not compact)
239
244
  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)
245
+ dependencies = True
246
+ env_map = True
247
+ code_notes = True
248
+ no_tree = True # agents never need the raw file tree
249
+ typer.echo("[agent] dependencies env-map code-notes (no-tree)", err=True)
247
250
 
248
251
  scanner = FileScanner(target, max_depth=effective_depth)
249
252
  raw_tree = scanner.scan_tree()
@@ -562,16 +565,20 @@ def main(
562
565
  p.replace("\\", "/") for p in flatten_file_tree(sm.file_tree)
563
566
  ]
564
567
 
565
- # LQN-05: top-15 dependencias directas de manifest/lockfile
568
+ # LQN-05: top-15 dependencias directas de manifest/lockfile, ordenadas por rol
566
569
  if dependency_analyzer is not None:
570
+ from sourcecode.dependency_analyzer import _ROLE_PRIORITY
571
+
567
572
  primary_ecosystem = sm.stacks[0].stack if sm.stacks else ""
568
573
  direct_deps = [
569
574
  d for d in sm.dependencies
570
575
  if d.scope != "transitive" and d.source in {"manifest", "lockfile"}
571
576
  ]
572
577
 
573
- def _dep_sort_key(d: Any) -> tuple[int, str]:
574
- return (0 if d.ecosystem == primary_ecosystem else 1, d.name.lower())
578
+ def _dep_sort_key(d: Any) -> tuple[int, int, str]:
579
+ role_order = _ROLE_PRIORITY.get(d.role or "runtime", 5)
580
+ eco_order = 0 if d.ecosystem == primary_ecosystem else 1
581
+ return (role_order, eco_order, d.name.lower())
575
582
 
576
583
  sm.key_dependencies = sorted(direct_deps, key=_dep_sort_key)[:15]
577
584
 
@@ -616,24 +623,23 @@ def main(
616
623
  for _finding in validate_cross_analyzer_consistency(sm, strict=False):
617
624
  typer.echo(f"[consistency] {_finding}", err=True)
618
625
 
619
- # 4. Serializar (con o sin modo compact)
620
- if compact:
626
+ # 4. Serializar
627
+ if agent:
628
+ data = agent_view(sm)
629
+ if not no_redact:
630
+ data = redact_dict(data)
631
+ content = json.dumps(data, indent=2, ensure_ascii=False)
632
+ elif compact:
621
633
  data = compact_view(sm, no_tree=no_tree)
622
- # Aplicar redaccion sobre el dict del compact view
623
634
  if not no_redact:
624
635
  data = redact_dict(data)
625
636
  content = json.dumps(data, indent=2, ensure_ascii=False)
626
637
  else:
627
- # Redactar sobre el dict serializado (SEC-01, SEC-03)
628
- raw_dict = asdict(sm)
629
- if no_tree:
630
- raw_dict.pop("file_tree", None)
631
- raw_dict.pop("file_paths", None)
638
+ raw_dict = standard_view(sm, include_tree=tree and not no_tree)
632
639
  if not no_redact:
633
640
  raw_dict = redact_dict(raw_dict)
634
641
 
635
642
  if format == "yaml":
636
- # Para YAML, serializar el dict directamente con ruamel.yaml
637
643
  from io import StringIO
638
644
 
639
645
  from ruamel.yaml import YAML
@@ -12,6 +12,77 @@ from ruamel.yaml import YAML
12
12
  from sourcecode.detectors.parsers import load_json_file, load_toml_file, read_text_lines
13
13
  from sourcecode.schema import DependencyRecord, DependencySummary
14
14
 
15
+ # ── Role inference ────────────────────────────────────────────────────────────
16
+
17
+ _PY_TESTTOOLS: frozenset[str] = frozenset({
18
+ "pytest", "unittest2", "hypothesis", "nose", "nose2", "coverage", "tox",
19
+ "factory-boy", "faker", "responses", "moto", "freezegun", "httpretty",
20
+ "respx", "pytest-cov", "pytest-mock", "pytest-asyncio", "pytest-xdist",
21
+ })
22
+ _PY_DEVTOOLS: frozenset[str] = frozenset({
23
+ "mypy", "ruff", "flake8", "black", "pylint", "bandit", "isort",
24
+ "pre-commit", "sphinx", "mkdocs", "pyright", "pyflakes", "autopep8",
25
+ "pycodestyle", "pydocstyle", "vulture",
26
+ })
27
+ _PY_SERIALIZATION: frozenset[str] = frozenset({
28
+ "ruamel.yaml", "pyyaml", "orjson", "msgpack", "ujson", "simplejson",
29
+ "cbor2", "tomli", "tomllib", "toml",
30
+ })
31
+ _PY_PARSING: frozenset[str] = frozenset({
32
+ "pathspec", "gitpython", "lxml", "beautifulsoup4", "html5lib",
33
+ "regex", "pyparsing",
34
+ })
35
+
36
+ _NODE_TESTTOOLS: frozenset[str] = frozenset({
37
+ "jest", "@jest/globals", "mocha", "jasmine", "chai", "karma",
38
+ "cypress", "playwright", "@playwright/test", "vitest", "@vitest/ui",
39
+ "sinon", "nock", "supertest",
40
+ })
41
+ _NODE_DEVTOOLS: frozenset[str] = frozenset({
42
+ "eslint", "prettier", "typescript", "@types/node", "webpack", "rollup",
43
+ "parcel", "@babel/core", "husky", "lint-staged", "nodemon", "ts-node", "tsx",
44
+ })
45
+
46
+ _DEV_SCOPES: frozenset[str] = frozenset({"dev", "optional"})
47
+
48
+ _ROLE_PRIORITY: dict[str, int] = {
49
+ "runtime": 0,
50
+ "parsing": 1,
51
+ "serialization": 2,
52
+ "testtool": 3,
53
+ "devtool": 4,
54
+ }
55
+
56
+
57
+ def _infer_role(name: str, ecosystem: str, scope: str) -> str:
58
+ """Infer dependency role: runtime | parsing | serialization | devtool | testtool."""
59
+ n = name.lower()
60
+ is_dev = scope in _DEV_SCOPES or scope.startswith(("optional:", "group:"))
61
+
62
+ if ecosystem == "python":
63
+ if n in _PY_TESTTOOLS or n.startswith("pytest-"):
64
+ return "testtool"
65
+ if n in _PY_DEVTOOLS:
66
+ return "devtool"
67
+ if is_dev:
68
+ return "devtool"
69
+ if n in _PY_SERIALIZATION:
70
+ return "serialization"
71
+ if n in _PY_PARSING:
72
+ return "parsing"
73
+ return "runtime"
74
+
75
+ if ecosystem == "nodejs":
76
+ if n in _NODE_TESTTOOLS or n.startswith("@testing-library/"):
77
+ return "testtool"
78
+ if n in _NODE_DEVTOOLS:
79
+ return "devtool"
80
+ if is_dev:
81
+ return "devtool"
82
+ return "runtime"
83
+
84
+ return "devtool" if is_dev else "runtime"
85
+
15
86
 
16
87
  class DependencyAnalyzer:
17
88
  """Resuelve dependencias desde manifests y lockfiles sin ejecutar toolchains."""
@@ -41,6 +112,10 @@ class DependencyAnalyzer:
41
112
  limitations.extend(handler_limitations)
42
113
 
43
114
  deduped = self._dedupe(records)
115
+ deduped = [
116
+ replace(r, role=_infer_role(r.name, r.ecosystem, r.scope))
117
+ for r in deduped
118
+ ]
44
119
  return deduped, self._build_summary(deduped, limitations)
45
120
 
46
121
  def merge_summaries(self, summaries: Iterable[DependencySummary]) -> DependencySummary:
@@ -15,6 +15,8 @@ class DetectionContext:
15
15
  root: Path
16
16
  file_tree: dict[str, Any]
17
17
  manifests: list[str] = field(default_factory=list)
18
+ manifest_types: dict[str, str] = field(default_factory=dict)
19
+ # manifest_types: {filename → "application"|"workspace"|"auxiliary"|"config"}
18
20
 
19
21
 
20
22
  class AbstractDetector(ABC):
@@ -43,6 +43,8 @@ class HeuristicDetector(AbstractDetector):
43
43
  paths = flatten_file_tree(context.file_tree)
44
44
  counts: Counter[str] = Counter()
45
45
  for path in paths:
46
+ if path.startswith("."):
47
+ continue
46
48
  for extension, stack in _EXTENSION_MAP.items():
47
49
  if path.endswith(extension):
48
50
  counts[stack] += 1
@@ -28,7 +28,12 @@ class NodejsDetector(AbstractDetector):
28
28
  priority = 20
29
29
 
30
30
  def can_detect(self, context: DetectionContext) -> bool:
31
- return "package.json" in context.manifests
31
+ if "package.json" not in context.manifests:
32
+ return False
33
+ if not path_exists_in_tree(context.file_tree, "package.json"):
34
+ return False
35
+ manifest_type = context.manifest_types.get("package.json", "application")
36
+ return manifest_type not in {"auxiliary", "config"}
32
37
 
33
38
  def detect(self, context: DetectionContext) -> tuple[list[StackDetection], list[EntryPoint]]:
34
39
  package_json = load_json_file(context.root / "package.json")
@@ -7,6 +7,7 @@ from typing import Any
7
7
  from sourcecode.classifier import TypeClassifier
8
8
  from sourcecode.detectors.base import AbstractDetector, DetectionContext
9
9
  from sourcecode.detectors.tooling import collect_tooling_signals, infer_package_manager
10
+ from sourcecode.scanner import classify_manifest
10
11
  from sourcecode.schema import EntryPoint, FrameworkDetection, StackDetection
11
12
 
12
13
  _CONFIDENCE_RANK = {"low": 0, "medium": 1, "high": 2}
@@ -30,7 +31,15 @@ class ProjectDetector:
30
31
  manifests: Sequence[str],
31
32
  ) -> tuple[list[StackDetection], list[EntryPoint], str | None]:
32
33
  manifest_names = [Path(manifest).name for manifest in manifests]
33
- context = DetectionContext(root=root, file_tree=file_tree, manifests=manifest_names)
34
+ manifest_types = {
35
+ Path(m).name: classify_manifest(m, root) for m in manifests
36
+ }
37
+ context = DetectionContext(
38
+ root=root,
39
+ file_tree=file_tree,
40
+ manifests=manifest_names,
41
+ manifest_types=manifest_types,
42
+ )
34
43
  merged_stacks: dict[str, StackDetection] = {}
35
44
  merged_entry_points: dict[str, EntryPoint] = {}
36
45
 
@@ -12,7 +12,7 @@ from sourcecode.detectors.base import (
12
12
  )
13
13
  from sourcecode.detectors.parsers import load_toml_file, read_text_lines, unique_strings
14
14
  from sourcecode.schema import FrameworkDetection
15
- from sourcecode.tree_utils import path_exists_in_tree
15
+ from sourcecode.tree_utils import find_files_by_name, path_exists_in_tree
16
16
 
17
17
  _FRAMEWORK_MAP = {
18
18
  "fastapi": "FastAPI",
@@ -146,35 +146,84 @@ class PythonDetector(AbstractDetector):
146
146
  entry_points: list[EntryPoint] = []
147
147
  declared = set()
148
148
  for path in unique_strings(declared_candidates):
149
- if path_exists_in_tree(context.file_tree, path):
150
- declared.add(path)
151
- kind = "cli" if path.endswith(("__main__.py", "main.py", "cli.py")) else "app"
149
+ actual = path
150
+ if not path_exists_in_tree(context.file_tree, path):
151
+ src_path = f"src/{path}"
152
+ if path_exists_in_tree(context.file_tree, src_path):
153
+ actual = src_path
154
+ else:
155
+ continue
156
+ declared.add(actual)
157
+ kind = "cli" if actual.endswith(("__main__.py", "main.py", "cli.py")) else "app"
158
+ entry_points.append(
159
+ EntryPoint(
160
+ path=actual,
161
+ stack="python",
162
+ kind=kind,
163
+ source="pyproject.toml",
164
+ confidence="high",
165
+ )
166
+ )
167
+
168
+ _CONVENTION_NAMES = {"cli.py", "__main__.py", "main.py", "app.py", "manage.py"}
169
+ seen_paths = set(declared)
170
+ for fname in _CONVENTION_NAMES:
171
+ for path in find_files_by_name(context.file_tree, fname):
172
+ if path in seen_paths or self._is_tooling_path(path):
173
+ continue
174
+ seen_paths.add(path)
175
+ kind = "cli" if fname in ("cli.py", "__main__.py", "main.py") else "app"
152
176
  entry_points.append(
153
177
  EntryPoint(
154
178
  path=path,
155
179
  stack="python",
156
180
  kind=kind,
157
- source="pyproject.toml",
158
- confidence="high",
181
+ source="convention",
182
+ confidence="medium",
159
183
  )
160
184
  )
161
185
 
162
- convention_candidates = ["cli.py", "__main__.py", "main.py", "app.py", "manage.py", "src/main.py"]
163
- for path in unique_strings(convention_candidates):
164
- if path in declared or not path_exists_in_tree(context.file_tree, path):
186
+ # code signal: scan Python files for if __name__ == "__main__" guard
187
+ for py_path in self._find_main_guard_files(context):
188
+ if py_path in seen_paths:
165
189
  continue
166
- kind = "cli" if path.endswith(("__main__.py", "main.py", "cli.py")) else "app"
190
+ seen_paths.add(py_path)
167
191
  entry_points.append(
168
192
  EntryPoint(
169
- path=path,
193
+ path=py_path,
170
194
  stack="python",
171
- kind=kind,
172
- source="convention",
173
- confidence="medium",
195
+ kind="script",
196
+ source="code_signal",
197
+ confidence="low",
174
198
  )
175
199
  )
200
+
176
201
  return entry_points
177
202
 
203
+ _MAIN_GUARD_RE = re.compile(r"^if __name__\s*==\s*['\"]__main__['\"]", re.MULTILINE)
204
+
205
+ def _find_main_guard_files(self, context: DetectionContext) -> list[str]:
206
+ from sourcecode.tree_utils import flatten_file_tree
207
+ results: list[str] = []
208
+ for path in flatten_file_tree(context.file_tree):
209
+ if not path.endswith(".py") or self._is_tooling_path(path):
210
+ continue
211
+ try:
212
+ content = (context.root / path).read_text(encoding="utf-8", errors="replace")
213
+ if self._MAIN_GUARD_RE.search(content):
214
+ results.append(path)
215
+ except OSError:
216
+ continue
217
+ return results
218
+
219
+ def _is_tooling_path(self, path: str) -> bool:
220
+ parts = path.split("/")
221
+ return any(
222
+ part.startswith(".")
223
+ or part in {"tests", "test", "__pycache__", "node_modules", "venv", ".venv"}
224
+ for part in parts[:-1]
225
+ )
226
+
178
227
  def _detect_package_manager(
179
228
  self, context: DetectionContext, *, pyproject: dict[str, Any] | None
180
229
  ) -> str:
@@ -48,6 +48,57 @@ MANIFEST_NAMES: frozenset[str] = frozenset({
48
48
  })
49
49
 
50
50
 
51
+ # Manifest types: application | library | tooling | workspace | config | auxiliary
52
+ _APPLICATION_MANIFESTS: frozenset[str] = frozenset({
53
+ "pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile",
54
+ "package.json", "go.mod", "Cargo.toml", "pom.xml", "build.gradle",
55
+ "build.gradle.kts", "composer.json", "Gemfile", "pubspec.yaml",
56
+ })
57
+
58
+ _CONFIG_MANIFESTS: frozenset[str] = frozenset({
59
+ ".npmrc", ".yarnrc", ".yarnrc.yml", "renovate.json", ".renovaterc",
60
+ "dependabot.yml",
61
+ })
62
+
63
+
64
+ def classify_manifest(manifest_path: str, root: Path) -> str:
65
+ """Classify a manifest as application | workspace | auxiliary | config | tooling.
66
+
67
+ Rules (in priority order):
68
+ 1. In a hidden directory (.claude/, .vscode/, etc.) → auxiliary
69
+ 2. In a known editor/agent config dir → auxiliary
70
+ 3. At root level with a known manifest name → application
71
+ 4. One level deep in a non-hidden dir → workspace
72
+ 5. Otherwise → auxiliary
73
+ """
74
+ try:
75
+ rel = Path(manifest_path).resolve().relative_to(root.resolve())
76
+ except ValueError:
77
+ rel = Path(manifest_path)
78
+
79
+ parts = rel.parts
80
+ if not parts:
81
+ return "auxiliary"
82
+
83
+ # Any hidden directory in the path → auxiliary tooling
84
+ if any(p.startswith(".") for p in parts[:-1]):
85
+ return "auxiliary"
86
+
87
+ name = parts[-1]
88
+
89
+ if len(parts) == 1:
90
+ if name in _CONFIG_MANIFESTS:
91
+ return "config"
92
+ if name in _APPLICATION_MANIFESTS:
93
+ return "application"
94
+ return "application" # unknown root manifest → treat as application
95
+
96
+ if len(parts) == 2:
97
+ return "workspace" # depth-1 sub-package
98
+
99
+ return "auxiliary"
100
+
101
+
51
102
  class FileScanner:
52
103
  """Escanea un directorio de proyecto y produce un arbol de ficheros filtrado.
53
104
 
@@ -172,10 +223,15 @@ class FileScanner:
172
223
  candidate = self.root / name
173
224
  if candidate.exists() and not candidate.is_symlink():
174
225
  manifests.append(str(candidate))
175
- # Profundidad 1: primer nivel
226
+ # Profundidad 1: primer nivel (excluir directorios ocultos — son tooling, no proyecto)
176
227
  try:
177
228
  for child in self.root.iterdir():
178
- if child.is_dir() and not child.is_symlink() and child.name not in self._excludes:
229
+ if (
230
+ child.is_dir()
231
+ and not child.is_symlink()
232
+ and child.name not in self._excludes
233
+ and not child.name.startswith(".")
234
+ ):
179
235
  for name in MANIFEST_NAMES:
180
236
  candidate = child / name
181
237
  if candidate.exists() and not candidate.is_symlink():
@@ -83,6 +83,7 @@ class DependencyRecord:
83
83
  parent: Optional[str] = None
84
84
  manifest_path: Optional[str] = None
85
85
  workspace: Optional[str] = None
86
+ role: Optional[str] = None # runtime | parsing | serialization | devtool | testtool
86
87
 
87
88
 
88
89
  @dataclass