sourcecode 0.20.0__py3-none-any.whl → 0.22.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.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]
sourcecode/classifier.py CHANGED
@@ -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
sourcecode/cli.py CHANGED
@@ -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,10 +164,14 @@ 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."""
171
+ # When a subcommand (e.g. prepare-context) is invoked, skip the main analysis.
172
+ if ctx.invoked_subcommand is not None:
173
+ return
174
+
166
175
  # Validar formato
167
176
  if format not in FORMAT_CHOICES:
168
177
  typer.echo(
@@ -212,7 +221,7 @@ def main(
212
221
  SourceMap,
213
222
  StackDetection,
214
223
  )
215
- 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
216
225
  from sourcecode.workspace import WorkspaceAnalyzer
217
226
 
218
227
  # 1. Escanear el directorio (SCAN-01 a SCAN-05)
@@ -231,15 +240,13 @@ def main(
231
240
  _java_min_depth = 8
232
241
  effective_depth = max(depth, _java_min_depth) if _is_java and depth < _java_min_depth else depth
233
242
 
234
- # --agent: auto-select flags based on project characteristics
243
+ # --agent: enable signal analyzers; output via agent_view (not compact)
235
244
  if agent:
236
- compact = True
237
- if _is_java:
238
- no_tree = True
239
- _agent_flags = ["--compact", "--dependencies", "--env-map", "--code-notes"]
240
- if no_tree:
241
- _agent_flags.append("--no-tree")
242
- 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)
243
250
 
244
251
  scanner = FileScanner(target, max_depth=effective_depth)
245
252
  raw_tree = scanner.scan_tree()
@@ -558,16 +565,20 @@ def main(
558
565
  p.replace("\\", "/") for p in flatten_file_tree(sm.file_tree)
559
566
  ]
560
567
 
561
- # LQN-05: top-15 dependencias directas de manifest/lockfile
568
+ # LQN-05: top-15 dependencias directas de manifest/lockfile, ordenadas por rol
562
569
  if dependency_analyzer is not None:
570
+ from sourcecode.dependency_analyzer import _ROLE_PRIORITY
571
+
563
572
  primary_ecosystem = sm.stacks[0].stack if sm.stacks else ""
564
573
  direct_deps = [
565
574
  d for d in sm.dependencies
566
575
  if d.scope != "transitive" and d.source in {"manifest", "lockfile"}
567
576
  ]
568
577
 
569
- def _dep_sort_key(d: Any) -> tuple[int, str]:
570
- 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())
571
582
 
572
583
  sm.key_dependencies = sorted(direct_deps, key=_dep_sort_key)[:15]
573
584
 
@@ -612,24 +623,23 @@ def main(
612
623
  for _finding in validate_cross_analyzer_consistency(sm, strict=False):
613
624
  typer.echo(f"[consistency] {_finding}", err=True)
614
625
 
615
- # 4. Serializar (con o sin modo compact)
616
- 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:
617
633
  data = compact_view(sm, no_tree=no_tree)
618
- # Aplicar redaccion sobre el dict del compact view
619
634
  if not no_redact:
620
635
  data = redact_dict(data)
621
636
  content = json.dumps(data, indent=2, ensure_ascii=False)
622
637
  else:
623
- # Redactar sobre el dict serializado (SEC-01, SEC-03)
624
- raw_dict = asdict(sm)
625
- if no_tree:
626
- raw_dict.pop("file_tree", None)
627
- raw_dict.pop("file_paths", None)
638
+ raw_dict = standard_view(sm, include_tree=tree and not no_tree)
628
639
  if not no_redact:
629
640
  raw_dict = redact_dict(raw_dict)
630
641
 
631
642
  if format == "yaml":
632
- # Para YAML, serializar el dict directamente con ruamel.yaml
633
643
  from io import StringIO
634
644
 
635
645
  from ruamel.yaml import YAML
@@ -654,31 +664,103 @@ def main(
654
664
 
655
665
  @app.command("prepare-context")
656
666
  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"),
667
+ task: Optional[str] = typer.Argument(
668
+ None,
669
+ help="Task: explain | fix-bug | refactor | generate-tests",
670
+ ),
671
+ path: Path = typer.Option(
672
+ Path("."),
673
+ "--path", "-p",
674
+ help="Project directory to analyze (default: current directory)",
675
+ ),
676
+ llm_prompt: bool = typer.Option(
677
+ False,
678
+ "--llm-prompt",
679
+ help="Append a ready-to-use LLM prompt to the output",
680
+ ),
681
+ task_help: bool = typer.Option(
682
+ False,
683
+ "--task-help",
684
+ help="List available tasks with descriptions and exit",
685
+ ),
686
+ dry_run: bool = typer.Option(
687
+ False,
688
+ "--dry-run",
689
+ help="Show what would be analyzed without running it",
690
+ ),
659
691
  ) -> None:
660
- """Prepara contexto mínimo optimizado para que un LLM modifique el código."""
661
- from dataclasses import asdict
692
+ """Prepare task-aware context optimized for LLM reasoning.
693
+
694
+ \b
695
+ Note: PATH must be provided before the subcommand (default: '.'):
696
+ sourcecode . prepare-context explain
697
+ sourcecode . prepare-context fix-bug --path /my/project
698
+ sourcecode . prepare-context generate-tests --llm-prompt
699
+ sourcecode . prepare-context --task-help
700
+ """
701
+ from sourcecode.prepare_context import TASKS, TaskContextBuilder
702
+
703
+ if task_help:
704
+ typer.echo("Available tasks:\n")
705
+ for name, spec in TASKS.items():
706
+ typer.echo(f" {name:<20} {spec.description}")
707
+ typer.echo(f" {'':20} Output: {spec.output_hint}\n")
708
+ raise typer.Exit()
662
709
 
663
- from sourcecode.prepare_context import ContextBuilder
710
+ if task is None:
711
+ typer.echo(
712
+ f"Error: task is required. Available: {', '.join(TASKS)}\n"
713
+ "Use --task-help for descriptions.",
714
+ err=True,
715
+ )
716
+ raise typer.Exit(code=1)
664
717
 
665
- target = path.resolve()
718
+ if task not in TASKS:
719
+ typer.echo(
720
+ f"Error: unknown task '{task}'. Available: {', '.join(TASKS)}",
721
+ err=True,
722
+ )
723
+ raise typer.Exit(code=1)
666
724
 
725
+ target = path.resolve()
667
726
  if not target.exists() or not target.is_dir():
668
727
  typer.echo(f"Error: '{target}' no es un directorio válido.", err=True)
669
728
  raise typer.Exit(code=1)
670
729
 
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,
730
+ if dry_run:
731
+ spec = TASKS[task]
732
+ typer.echo(f"task: {task}")
733
+ typer.echo(f"goal: {spec.goal}")
734
+ typer.echo(f"path: {target}")
735
+ typer.echo(f"analyzers: dependencies={'yes' if spec.enable_dependencies else 'no'}"
736
+ f", code_notes={'yes' if spec.enable_code_notes else 'no'}")
737
+ typer.echo(f"output: {spec.output_hint}")
738
+ raise typer.Exit()
739
+
740
+ from dataclasses import asdict
741
+
742
+ builder = TaskContextBuilder(target)
743
+ output = builder.build(task)
744
+
745
+ out: dict[str, Any] = {
746
+ "task": output.task,
747
+ "goal": output.goal,
748
+ "project_summary": output.project_summary,
749
+ "architecture_summary": output.architecture_summary,
750
+ "relevant_files": [asdict(f) for f in output.relevant_files],
751
+ "key_dependencies": output.key_dependencies,
682
752
  }
753
+ if output.suspected_areas:
754
+ out["suspected_areas"] = output.suspected_areas
755
+ if output.improvement_opportunities:
756
+ out["improvement_opportunities"] = output.improvement_opportunities
757
+ if output.test_gaps:
758
+ out["test_gaps"] = output.test_gaps
759
+ if output.code_notes_summary:
760
+ out["code_notes_summary"] = output.code_notes_summary
761
+ if output.limitations:
762
+ out["limitations"] = output.limitations
763
+ if llm_prompt:
764
+ out["llm_prompt"] = builder.render_prompt(output)
683
765
 
684
766
  typer.echo(json.dumps(out, indent=2, ensure_ascii=False))
@@ -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: