sourcecode 0.25.0__tar.gz → 0.26.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 (121) hide show
  1. {sourcecode-0.25.0 → sourcecode-0.26.0}/.gitignore +3 -0
  2. {sourcecode-0.25.0 → sourcecode-0.26.0}/PKG-INFO +1 -1
  3. {sourcecode-0.25.0 → sourcecode-0.26.0}/docs/schema.md +2 -2
  4. {sourcecode-0.25.0 → sourcecode-0.26.0}/pyproject.toml +1 -1
  5. {sourcecode-0.25.0 → sourcecode-0.26.0}/src/sourcecode/__init__.py +1 -1
  6. {sourcecode-0.25.0 → sourcecode-0.26.0}/src/sourcecode/architecture_summary.py +40 -2
  7. {sourcecode-0.25.0 → sourcecode-0.26.0}/src/sourcecode/cli.py +8 -0
  8. sourcecode-0.26.0/src/sourcecode/detectors/nodejs.py +265 -0
  9. {sourcecode-0.25.0 → sourcecode-0.26.0}/src/sourcecode/graph_analyzer.py +50 -1
  10. {sourcecode-0.25.0 → sourcecode-0.26.0}/src/sourcecode/prepare_context.py +73 -4
  11. sourcecode-0.26.0/src/sourcecode/relevance_scorer.py +182 -0
  12. sourcecode-0.26.0/src/sourcecode/runtime_classifier.py +331 -0
  13. {sourcecode-0.25.0 → sourcecode-0.26.0}/src/sourcecode/schema.py +35 -2
  14. {sourcecode-0.25.0 → sourcecode-0.26.0}/src/sourcecode/serializer.py +32 -2
  15. {sourcecode-0.25.0 → sourcecode-0.26.0}/src/sourcecode/summarizer.py +75 -1
  16. sourcecode-0.26.0/tests/__init__.py +0 -0
  17. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/test_dependency_analyzer_node_python.py +1 -1
  18. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/test_detector_nodejs.py +3 -2
  19. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/test_hybrid_inference.py +1 -1
  20. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/test_integration.py +1 -1
  21. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/test_integration_dependencies.py +1 -1
  22. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/test_schema.py +2 -2
  23. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/test_signal_hierarchy.py +1 -1
  24. sourcecode-0.25.0/src/sourcecode/detectors/nodejs.py +0 -175
  25. {sourcecode-0.25.0 → sourcecode-0.26.0}/.ruff.toml +0 -0
  26. {sourcecode-0.25.0 → sourcecode-0.26.0}/README.md +0 -0
  27. /sourcecode-0.25.0/tests/__init__.py → /sourcecode-0.26.0/raw +0 -0
  28. {sourcecode-0.25.0 → sourcecode-0.26.0}/src/sourcecode/architecture_analyzer.py +0 -0
  29. {sourcecode-0.25.0 → sourcecode-0.26.0}/src/sourcecode/classifier.py +0 -0
  30. {sourcecode-0.25.0 → sourcecode-0.26.0}/src/sourcecode/code_notes_analyzer.py +0 -0
  31. {sourcecode-0.25.0 → sourcecode-0.26.0}/src/sourcecode/confidence_analyzer.py +0 -0
  32. {sourcecode-0.25.0 → sourcecode-0.26.0}/src/sourcecode/context_summarizer.py +0 -0
  33. {sourcecode-0.25.0 → sourcecode-0.26.0}/src/sourcecode/coverage_parser.py +0 -0
  34. {sourcecode-0.25.0 → sourcecode-0.26.0}/src/sourcecode/dependency_analyzer.py +0 -0
  35. {sourcecode-0.25.0 → sourcecode-0.26.0}/src/sourcecode/detectors/__init__.py +0 -0
  36. {sourcecode-0.25.0 → sourcecode-0.26.0}/src/sourcecode/detectors/base.py +0 -0
  37. {sourcecode-0.25.0 → sourcecode-0.26.0}/src/sourcecode/detectors/csproj_parser.py +0 -0
  38. {sourcecode-0.25.0 → sourcecode-0.26.0}/src/sourcecode/detectors/dart.py +0 -0
  39. {sourcecode-0.25.0 → sourcecode-0.26.0}/src/sourcecode/detectors/dotnet.py +0 -0
  40. {sourcecode-0.25.0 → sourcecode-0.26.0}/src/sourcecode/detectors/elixir.py +0 -0
  41. {sourcecode-0.25.0 → sourcecode-0.26.0}/src/sourcecode/detectors/go.py +0 -0
  42. {sourcecode-0.25.0 → sourcecode-0.26.0}/src/sourcecode/detectors/heuristic.py +0 -0
  43. {sourcecode-0.25.0 → sourcecode-0.26.0}/src/sourcecode/detectors/hybrid.py +0 -0
  44. {sourcecode-0.25.0 → sourcecode-0.26.0}/src/sourcecode/detectors/java.py +0 -0
  45. {sourcecode-0.25.0 → sourcecode-0.26.0}/src/sourcecode/detectors/jvm_ext.py +0 -0
  46. {sourcecode-0.25.0 → sourcecode-0.26.0}/src/sourcecode/detectors/parsers.py +0 -0
  47. {sourcecode-0.25.0 → sourcecode-0.26.0}/src/sourcecode/detectors/php.py +0 -0
  48. {sourcecode-0.25.0 → sourcecode-0.26.0}/src/sourcecode/detectors/project.py +0 -0
  49. {sourcecode-0.25.0 → sourcecode-0.26.0}/src/sourcecode/detectors/python.py +0 -0
  50. {sourcecode-0.25.0 → sourcecode-0.26.0}/src/sourcecode/detectors/ruby.py +0 -0
  51. {sourcecode-0.25.0 → sourcecode-0.26.0}/src/sourcecode/detectors/rust.py +0 -0
  52. {sourcecode-0.25.0 → sourcecode-0.26.0}/src/sourcecode/detectors/systems.py +0 -0
  53. {sourcecode-0.25.0 → sourcecode-0.26.0}/src/sourcecode/detectors/terraform.py +0 -0
  54. {sourcecode-0.25.0 → sourcecode-0.26.0}/src/sourcecode/detectors/tooling.py +0 -0
  55. {sourcecode-0.25.0 → sourcecode-0.26.0}/src/sourcecode/doc_analyzer.py +0 -0
  56. {sourcecode-0.25.0 → sourcecode-0.26.0}/src/sourcecode/env_analyzer.py +0 -0
  57. {sourcecode-0.25.0 → sourcecode-0.26.0}/src/sourcecode/git_analyzer.py +0 -0
  58. {sourcecode-0.25.0 → sourcecode-0.26.0}/src/sourcecode/metrics_analyzer.py +0 -0
  59. {sourcecode-0.25.0 → sourcecode-0.26.0}/src/sourcecode/redactor.py +0 -0
  60. {sourcecode-0.25.0 → sourcecode-0.26.0}/src/sourcecode/scanner.py +0 -0
  61. {sourcecode-0.25.0 → sourcecode-0.26.0}/src/sourcecode/semantic_analyzer.py +0 -0
  62. {sourcecode-0.25.0 → sourcecode-0.26.0}/src/sourcecode/tree_utils.py +0 -0
  63. {sourcecode-0.25.0 → sourcecode-0.26.0}/src/sourcecode/workspace.py +0 -0
  64. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/conftest.py +0 -0
  65. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/fixtures/coverage.xml +0 -0
  66. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/fixtures/fastapi_app/pyproject.toml +0 -0
  67. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/fixtures/fastapi_app/src/main.py +0 -0
  68. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/fixtures/go_service/cmd/api/main.go +0 -0
  69. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/fixtures/go_service/go.mod +0 -0
  70. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/fixtures/jacoco.xml +0 -0
  71. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/fixtures/lcov.info +0 -0
  72. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/fixtures/nextjs_app/app/page.tsx +0 -0
  73. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/fixtures/nextjs_app/package.json +0 -0
  74. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/fixtures/nextjs_app/pnpm-lock.yaml +0 -0
  75. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/fixtures/pnpm_monorepo/apps/web/app/page.tsx +0 -0
  76. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/fixtures/pnpm_monorepo/apps/web/package.json +0 -0
  77. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/fixtures/pnpm_monorepo/packages/api/main.py +0 -0
  78. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/fixtures/pnpm_monorepo/packages/api/pyproject.toml +0 -0
  79. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/fixtures/pnpm_monorepo/pnpm-workspace.yaml +0 -0
  80. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/test_architecture_analyzer.py +0 -0
  81. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/test_architecture_summary.py +0 -0
  82. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/test_classifier.py +0 -0
  83. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/test_cli.py +0 -0
  84. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/test_code_notes_analyzer.py +0 -0
  85. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/test_coverage_parser.py +0 -0
  86. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/test_cross_consistency.py +0 -0
  87. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/test_dependency_analyzer_polyglot.py +0 -0
  88. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/test_dependency_schema.py +0 -0
  89. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/test_detector_dotnet.py +0 -0
  90. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/test_detector_go_rust_java.py +0 -0
  91. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/test_detector_php_ruby_dart.py +0 -0
  92. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/test_detector_python.py +0 -0
  93. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/test_detector_universal_managed.py +0 -0
  94. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/test_detector_universal_systems.py +0 -0
  95. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/test_detectors_base.py +0 -0
  96. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/test_doc_analyzer_jsdom.py +0 -0
  97. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/test_doc_analyzer_python.py +0 -0
  98. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/test_graph_analyzer_polyglot.py +0 -0
  99. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/test_graph_analyzer_python_node.py +0 -0
  100. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/test_graph_schema.py +0 -0
  101. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/test_integration_detection.py +0 -0
  102. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/test_integration_docs.py +0 -0
  103. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/test_integration_graph_modules.py +0 -0
  104. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/test_integration_lqn.py +0 -0
  105. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/test_integration_metrics.py +0 -0
  106. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/test_integration_multistack.py +0 -0
  107. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/test_integration_semantics.py +0 -0
  108. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/test_integration_universal.py +0 -0
  109. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/test_metrics_analyzer.py +0 -0
  110. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/test_packaging.py +0 -0
  111. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/test_phase1_improvements.py +0 -0
  112. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/test_real_projects.py +0 -0
  113. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/test_redactor.py +0 -0
  114. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/test_scanner.py +0 -0
  115. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/test_schema_normalization.py +0 -0
  116. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/test_semantic_analyzer_node.py +0 -0
  117. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/test_semantic_analyzer_python.py +0 -0
  118. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/test_semantic_import_resolution.py +0 -0
  119. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/test_semantic_schema.py +0 -0
  120. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/test_summarizer.py +0 -0
  121. {sourcecode-0.25.0 → sourcecode-0.26.0}/tests/test_workspace_analyzer.py +0 -0
@@ -24,7 +24,10 @@ src/*.egg-info/
24
24
 
25
25
  # Environment / secrets
26
26
  *.env
27
+ .env.*
27
28
  *.secret.json
29
+ *.local.json
30
+ *.local.toml
28
31
 
29
32
  # OS cruft
30
33
  .DS_Store
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sourcecode
3
- Version: 0.25.0
3
+ Version: 0.26.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.25.0",
84
+ "sourcecode_version": "0.26.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.25.0",
648
+ "sourcecode_version": "0.26.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.25.0"
7
+ version = "0.26.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.25.0"
3
+ __version__ = "0.26.0"
@@ -9,6 +9,12 @@ from sourcecode.schema import EntryPoint, SourceMap, StackDetection
9
9
  from sourcecode.tree_utils import flatten_file_tree
10
10
 
11
11
  _TOOLING_PREFIXES = (".claude/", ".vscode/", "bin/")
12
+ _AUXILIARY_PATH_PARTS: frozenset[str] = frozenset({
13
+ "benchmark", "benchmarks", "bench", "benches",
14
+ "demo", "demos", "example", "examples",
15
+ "docs", "doc", "fixture", "fixtures",
16
+ "playground", "playgrounds", "sandbox",
17
+ })
12
18
  _PYTHON_EXTENSIONS = {".py"}
13
19
  _NODE_EXTENSIONS = {".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"}
14
20
  _GO_EXTENSIONS = {".go"}
@@ -52,10 +58,12 @@ class ArchitectureSummarizer:
52
58
  # Rich path: use all available signals (stacks, arch analysis, graph)
53
59
  rich_lines = self._build_rich_lines(sm)
54
60
 
55
- # Stack-specific entry point analysis (existing logic)
61
+ # Stack-specific entry point analysis: exclude tooling + auxiliary paths
56
62
  entry_points = [
57
63
  entry for entry in sm.entry_points
58
64
  if not self._is_tooling_path(entry.path)
65
+ and not self._is_auxiliary_path(entry.path)
66
+ and entry.entrypoint_type not in ("benchmark", "example")
59
67
  ]
60
68
  if not entry_points:
61
69
  fallback = self._infer_fallback_entry_points(file_paths, sm.stacks)
@@ -147,7 +155,31 @@ class ArchitectureSummarizer:
147
155
  if primary is None:
148
156
  return ""
149
157
  stack_label = _STACK_LABELS.get(primary.stack, primary.stack)
150
- fw_names = [f.name for s in sm.stacks for f in s.frameworks[:2]][:3]
158
+
159
+ # For monorepos: only show frameworks from runtime-critical packages,
160
+ # not every framework found in every workspace (avoids "React project" misclassification)
161
+ if sm.project_type == "monorepo" and sm.monorepo_packages:
162
+ runtime_roles = {"runtime_core", "plugin_host", "backend_runtime"}
163
+ runtime_paths = {p.path for p in sm.monorepo_packages if p.architectural_role in runtime_roles}
164
+ fw_names = [
165
+ f.name
166
+ for s in sm.stacks
167
+ if not s.workspace or s.workspace in runtime_paths
168
+ for f in s.frameworks[:2]
169
+ ]
170
+ fw_names = list(dict.fromkeys(fw_names))[:3] # dedup, preserve order
171
+ else:
172
+ # Filter out frameworks from auxiliary or tooling stacks (docs/, benchmarks/, etc.)
173
+ fw_names = [
174
+ f.name
175
+ for s in sm.stacks
176
+ if not self._is_tooling_path(s.root or "")
177
+ and not self._is_tooling_path(s.workspace or "")
178
+ and not self._is_auxiliary_path(s.root or "")
179
+ and not self._is_auxiliary_path(s.workspace or "")
180
+ for f in s.frameworks[:2]
181
+ ][:3]
182
+
151
183
  fw_str = f" using {', '.join(fw_names)}" if fw_names else ""
152
184
  if runtime:
153
185
  return f"{stack_label} {runtime.lower()}{fw_str}."
@@ -443,6 +475,12 @@ class ArchitectureSummarizer:
443
475
  except OSError:
444
476
  return None
445
477
 
478
+ def _is_auxiliary_path(self, path: str | None) -> bool:
479
+ if not path:
480
+ return False
481
+ parts = path.replace("\\", "/").strip("/").lower().split("/")
482
+ return any(p in _AUXILIARY_PATH_PARTS for p in parts)
483
+
446
484
  def _is_tooling_path(self, path: str | None) -> bool:
447
485
  if not path:
448
486
  return False
@@ -555,6 +555,14 @@ def main(
555
555
  semantic_summary=sem_sum,
556
556
  )
557
557
 
558
+ # Runtime architecture — classify workspace packages for structural summaries
559
+ if workspace_analysis.workspaces:
560
+ from sourcecode.runtime_classifier import RuntimeClassifier
561
+ sm.monorepo_packages = RuntimeClassifier().classify(
562
+ target,
563
+ [ws.path for ws in workspace_analysis.workspaces],
564
+ )
565
+
558
566
  # Phase 9: LLM Output Quality — poblar campos derivados
559
567
  from sourcecode.architecture_summary import ArchitectureSummarizer
560
568
  from sourcecode.summarizer import ProjectSummarizer
@@ -0,0 +1,265 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from sourcecode.detectors.base import (
6
+ AbstractDetector,
7
+ DetectionContext,
8
+ EntryPoint,
9
+ StackDetection,
10
+ )
11
+ from sourcecode.detectors.parsers import load_json_file, unique_strings
12
+ from sourcecode.schema import FrameworkDetection
13
+ from sourcecode.tree_utils import path_exists_in_tree
14
+
15
+ _FRAMEWORK_MAP = {
16
+ "next": "Next.js",
17
+ "express": "Express",
18
+ "react": "React",
19
+ "vite": "Vite",
20
+ "vue": "Vue",
21
+ "svelte": "Svelte",
22
+ "@nestjs/core": "NestJS",
23
+ "fastify": "Fastify",
24
+ "hono": "Hono",
25
+ "@remix-run/node": "Remix",
26
+ "@remix-run/react": "Remix",
27
+ "astro": "Astro",
28
+ "nuxt": "Nuxt",
29
+ "@nuxt/kit": "Nuxt",
30
+ "gatsby": "Gatsby",
31
+ "@angular/core": "Angular",
32
+ "solid-js": "SolidJS",
33
+ "@trpc/server": "tRPC",
34
+ "graphql": "GraphQL",
35
+ "@apollo/server": "Apollo",
36
+ "apollo-server": "Apollo",
37
+ "koa": "Koa",
38
+ "elysia": "Elysia",
39
+ }
40
+
41
+
42
+ class NodejsDetector(AbstractDetector):
43
+ name = "nodejs"
44
+ priority = 20
45
+
46
+ def can_detect(self, context: DetectionContext) -> bool:
47
+ if "package.json" not in context.manifests:
48
+ return False
49
+ if not path_exists_in_tree(context.file_tree, "package.json"):
50
+ return False
51
+ manifest_type = context.manifest_types.get("package.json", "application")
52
+ return manifest_type not in {"auxiliary", "config"}
53
+
54
+ def detect(self, context: DetectionContext) -> tuple[list[StackDetection], list[EntryPoint]]:
55
+ package_json = load_json_file(context.root / "package.json")
56
+ if package_json is None:
57
+ return [], []
58
+
59
+ from sourcecode.detectors.hybrid import merge_framework_detections, scan_for_frameworks
60
+
61
+ dependency_names = self._collect_dependency_names(package_json)
62
+ seen_fw: set[str] = set()
63
+ manifest_frameworks = []
64
+ for pkg_name, label in _FRAMEWORK_MAP.items():
65
+ if pkg_name in dependency_names and label not in seen_fw:
66
+ seen_fw.add(label)
67
+ manifest_frameworks.append(FrameworkDetection(name=label, source="package.json"))
68
+
69
+ package_manager = self._detect_package_manager(context)
70
+ entry_points = self._collect_entry_points(context, package_json)
71
+ priority = [ep.path for ep in entry_points]
72
+ import_frameworks = scan_for_frameworks(context.root, context.file_tree, "nodejs", priority_paths=priority)
73
+ frameworks = merge_framework_detections(manifest_frameworks, import_frameworks)
74
+ signals = self._detect_monorepo_signals(context, package_json)
75
+
76
+ stack = StackDetection(
77
+ stack="nodejs",
78
+ detection_method="manifest",
79
+ confidence="high",
80
+ frameworks=frameworks,
81
+ package_manager=package_manager,
82
+ manifests=["package.json"],
83
+ signals=signals,
84
+ )
85
+ return [stack], entry_points
86
+
87
+ def _detect_monorepo_signals(
88
+ self, context: DetectionContext, package_json: dict[str, Any]
89
+ ) -> list[str]:
90
+ signals: list[str] = []
91
+ if path_exists_in_tree(context.file_tree, "turbo.json"):
92
+ signals.append("monorepo:turbo")
93
+ elif path_exists_in_tree(context.file_tree, "nx.json"):
94
+ signals.append("monorepo:nx")
95
+ elif path_exists_in_tree(context.file_tree, "pnpm-workspace.yaml"):
96
+ signals.append("monorepo:pnpm")
97
+ elif isinstance(package_json.get("workspaces"), (list, dict)):
98
+ signals.append("monorepo:npm-workspaces")
99
+ return signals
100
+
101
+ def _collect_dependency_names(self, package_json: dict[str, Any]) -> set[str]:
102
+ names: set[str] = set()
103
+ for field in ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies"):
104
+ raw = package_json.get(field, {})
105
+ if isinstance(raw, dict):
106
+ names.update(str(name) for name in raw)
107
+ return names
108
+
109
+ def _detect_package_manager(self, context: DetectionContext) -> str | None:
110
+ for filename, package_manager in (
111
+ ("bun.lockb", "bun"),
112
+ ("pnpm-lock.yaml", "pnpm"),
113
+ ("package-lock.json", "npm"),
114
+ ("yarn.lock", "yarn"),
115
+ ):
116
+ if path_exists_in_tree(context.file_tree, filename):
117
+ return package_manager
118
+ return None
119
+
120
+ # Directories that indicate a non-production entry point
121
+ _AUXILIARY_DIRS: frozenset[str] = frozenset({
122
+ "benchmark", "benchmarks", "bench",
123
+ "example", "examples",
124
+ "demo", "demos",
125
+ "playground", "playgrounds",
126
+ "fixture", "fixtures",
127
+ "sandbox", "e2e", "docs",
128
+ })
129
+
130
+ def _collect_entry_points(
131
+ self, context: DetectionContext, package_json: dict[str, Any]
132
+ ) -> list[EntryPoint]:
133
+ entry_points: list[EntryPoint] = []
134
+ seen: set[str] = set()
135
+
136
+ # Priority 1: package.json scripts — most reliable signal
137
+ scripts = package_json.get("scripts", {})
138
+ if isinstance(scripts, dict):
139
+ for script_name, script_cmd in scripts.items():
140
+ if not isinstance(script_cmd, str):
141
+ continue
142
+ ep_type, kind = self._classify_script(script_name)
143
+ if ep_type is None:
144
+ continue
145
+ # Extract file path from script command
146
+ path = self._extract_script_path(script_cmd, context)
147
+ if path and path not in seen and path_exists_in_tree(context.file_tree, path):
148
+ seen.add(path)
149
+ if not self._is_auxiliary_path(path):
150
+ entry_points.append(EntryPoint(
151
+ path=path,
152
+ stack="nodejs",
153
+ kind=kind,
154
+ source="package.json#scripts",
155
+ confidence="high",
156
+ reason=f"script:{script_name}",
157
+ evidence=f"scripts.{script_name} = {script_cmd!r:.80}",
158
+ entrypoint_type=ep_type,
159
+ ))
160
+
161
+ # Priority 2: package.json bin — CLI production entry points
162
+ bin_field = package_json.get("bin")
163
+ bin_paths: list[str] = []
164
+ if isinstance(bin_field, str) and bin_field.strip():
165
+ bin_paths.append(bin_field.strip())
166
+ elif isinstance(bin_field, dict):
167
+ bin_paths.extend(
168
+ str(v).strip() for v in bin_field.values()
169
+ if isinstance(v, str) and v.strip()
170
+ )
171
+ for path in bin_paths:
172
+ if path not in seen and path_exists_in_tree(context.file_tree, path):
173
+ seen.add(path)
174
+ entry_points.append(EntryPoint(
175
+ path=path,
176
+ stack="nodejs",
177
+ kind="cli",
178
+ source="package.json#bin",
179
+ confidence="high",
180
+ reason="bin",
181
+ evidence="declared in package.json bin field",
182
+ entrypoint_type="production",
183
+ ))
184
+
185
+ # Priority 3: package.json main (library/module entry)
186
+ main = package_json.get("main")
187
+ if isinstance(main, str) and main.strip():
188
+ path = main.strip()
189
+ if path not in seen and path_exists_in_tree(context.file_tree, path):
190
+ seen.add(path)
191
+ entry_points.append(EntryPoint(
192
+ path=path,
193
+ stack="nodejs",
194
+ kind="server",
195
+ source="package.json",
196
+ confidence="high",
197
+ entrypoint_type="production",
198
+ ))
199
+
200
+ # Priority 4: filename conventions (last resort — penalize auxiliary dirs)
201
+ for path in [
202
+ "server.js", "server.ts",
203
+ "src/index.js", "src/index.ts",
204
+ "src/main.js", "src/main.ts", "src/main.tsx",
205
+ "app/page.tsx", "pages/index.js",
206
+ ]:
207
+ if path in seen or not path_exists_in_tree(context.file_tree, path):
208
+ continue
209
+ ep_type = self._path_entrypoint_type(path)
210
+ kind = "web" if path.startswith(("app/", "pages/")) else "server"
211
+ entry_points.append(EntryPoint(
212
+ path=path,
213
+ stack="nodejs",
214
+ kind=kind,
215
+ source="convention",
216
+ confidence="medium",
217
+ reason="convention",
218
+ entrypoint_type=ep_type,
219
+ ))
220
+
221
+ return entry_points
222
+
223
+ def _classify_script(self, script_name: str) -> tuple[str | None, str]:
224
+ """Map script name → (entrypoint_type, kind). Returns (None, '') to skip."""
225
+ lower = script_name.lower()
226
+ if lower in ("start", "serve"):
227
+ return "production", "server"
228
+ if lower in ("dev", "develop", "watch"):
229
+ return "development", "server"
230
+ if lower in ("cli", "bin"):
231
+ return "production", "cli"
232
+ if "benchmark" in lower or lower == "bench":
233
+ return "benchmark", "script"
234
+ if lower.startswith("example") or lower.startswith("demo"):
235
+ return "example", "script"
236
+ return None, ""
237
+
238
+ def _extract_script_path(self, cmd: str, context: DetectionContext) -> str | None:
239
+ """Extract a likely source file path from a script command string."""
240
+ import shlex
241
+ try:
242
+ parts = shlex.split(cmd)
243
+ except ValueError:
244
+ parts = cmd.split()
245
+ # Skip executor prefixes: node, ts-node, tsx, nodemon, npx, etc.
246
+ _SKIP = {"node", "ts-node", "tsx", "nodemon", "npx", "pnpm", "yarn", "npm", "bun",
247
+ "--inspect", "--inspect-brk", "--require", "-r", "run", "exec"}
248
+ for part in parts:
249
+ if part.startswith("-") or part in _SKIP or "=" in part:
250
+ continue
251
+ # It looks like a file path (has slash or known extension)
252
+ p = part.strip("'\"")
253
+ if ("/" in p or p.endswith((".js", ".ts", ".mjs", ".cjs"))) and not p.startswith("@"):
254
+ return p
255
+ return None
256
+
257
+ def _is_auxiliary_path(self, path: str) -> bool:
258
+ norm = path.replace("\\", "/")
259
+ parts = norm.split("/")
260
+ return any(p.lower() in self._AUXILIARY_DIRS for p in parts)
261
+
262
+ def _path_entrypoint_type(self, path: str) -> str:
263
+ if self._is_auxiliary_path(path):
264
+ return "example"
265
+ return "production"
@@ -15,6 +15,44 @@ GraphDetail = Literal["high", "medium", "full"]
15
15
  GraphImportance = Literal["high", "medium", "low"]
16
16
 
17
17
 
18
+ # Node.js stdlib modules — omit from graph unless --graph-detail full
19
+ _NODE_STDLIB: frozenset[str] = frozenset({
20
+ "fs", "fs/promises", "path", "os", "child_process", "process",
21
+ "net", "http", "https", "http2", "stream", "events", "util",
22
+ "crypto", "buffer", "url", "querystring", "string_decoder",
23
+ "readline", "repl", "assert", "dns", "dgram", "vm",
24
+ "worker_threads", "cluster", "module", "v8", "perf_hooks",
25
+ "async_hooks", "inspector", "tty", "zlib", "domain",
26
+ "timers", "timers/promises", "console", "constants",
27
+ "node:fs", "node:path", "node:os", "node:crypto", "node:buffer",
28
+ "node:stream", "node:events", "node:util", "node:http", "node:https",
29
+ "node:child_process", "node:process", "node:worker_threads",
30
+ })
31
+
32
+ # Python stdlib modules — omit from graph unless --graph-detail full
33
+ _PYTHON_STDLIB: frozenset[str] = frozenset({
34
+ "os", "sys", "re", "io", "abc", "ast", "copy", "csv",
35
+ "enum", "functools", "itertools", "json", "logging", "math",
36
+ "pathlib", "random", "shutil", "string", "subprocess",
37
+ "threading", "time", "typing", "uuid", "warnings",
38
+ "collections", "contextlib", "dataclasses", "datetime",
39
+ "decimal", "difflib", "email", "glob", "hashlib", "hmac",
40
+ "http", "importlib", "inspect", "operator", "pickle",
41
+ "platform", "pprint", "queue", "signal", "socket",
42
+ "sqlite3", "stat", "struct", "tempfile", "textwrap",
43
+ "traceback", "unicodedata", "unittest", "urllib", "weakref",
44
+ "xml", "zipfile", "zlib",
45
+ })
46
+
47
+ # External packages that add noise without structural signal
48
+ _NODE_NOISE_PACKAGES: frozenset[str] = frozenset({
49
+ "lodash", "moment", "date-fns", "uuid", "chalk", "debug",
50
+ "yargs", "commander", "dotenv", "cross-env",
51
+ "eslint", "prettier", "typescript", "ts-node",
52
+ "@types/node", "@types/react",
53
+ })
54
+
55
+
18
56
  class GraphAnalyzer:
19
57
  """Construye un grafo estructural parcial y seguro del proyecto."""
20
58
 
@@ -908,7 +946,14 @@ class GraphAnalyzer:
908
946
  )
909
947
  )
910
948
  else:
911
- limitations.append(f"node_external:{relative_path}:{spec}")
949
+ # Silently skip stdlib and noise packages — they add no structural signal
950
+ bare = spec.removeprefix("node:")
951
+ root_pkg = bare.split("/")[0]
952
+ if (spec not in _NODE_STDLIB
953
+ and bare not in _NODE_STDLIB
954
+ and root_pkg not in _NODE_STDLIB
955
+ and root_pkg not in _NODE_NOISE_PACKAGES):
956
+ limitations.append(f"node_external:{relative_path}:{spec}")
912
957
 
913
958
  return nodes, edges, limitations
914
959
 
@@ -1121,6 +1166,10 @@ class GraphAnalyzer:
1121
1166
  def _resolve_python_import(
1122
1167
  self, module_name: str, module_map: dict[str, str]
1123
1168
  ) -> Optional[str]:
1169
+ # Skip stdlib modules — they add no structural signal to the project graph
1170
+ root_module = module_name.split(".")[0]
1171
+ if root_module in _PYTHON_STDLIB:
1172
+ return None
1124
1173
  for path, candidate in module_map.items():
1125
1174
  if candidate == module_name:
1126
1175
  return path
@@ -359,10 +359,15 @@ class TaskContextBuilder:
359
359
 
360
360
  # ── 2. Detect stacks + entry points ───────────────────────────────
361
361
  from sourcecode.detectors import ProjectDetector, build_default_detectors
362
+ from sourcecode.workspace import WorkspaceAnalyzer
362
363
 
363
364
  detector = ProjectDetector(build_default_detectors())
365
+ workspace_analysis = WorkspaceAnalyzer().analyze(self.root, manifests)
364
366
  stacks, entry_points, _ = detector.detect(self.root, file_tree, manifests)
365
- stacks, project_type = detector.classify_results(file_tree, stacks, entry_points)
367
+ stacks, project_type = detector.classify_results(
368
+ file_tree, stacks, entry_points,
369
+ project_type_override="monorepo" if workspace_analysis.is_monorepo else None,
370
+ )
366
371
 
367
372
  # ── 3. Summarize ───────────────────────────────────────────────────
368
373
  from sourcecode.schema import AnalysisMetadata, SourceMap
@@ -378,6 +383,14 @@ class TaskContextBuilder:
378
383
  )
379
384
  sm.file_paths = all_paths
380
385
 
386
+ # Classify workspace packages for structural context
387
+ if workspace_analysis.workspaces:
388
+ from sourcecode.runtime_classifier import RuntimeClassifier
389
+ sm.monorepo_packages = RuntimeClassifier().classify(
390
+ self.root,
391
+ [ws.path for ws in workspace_analysis.workspaces],
392
+ )
393
+
381
394
  project_summary = ProjectSummarizer(self.root).generate(sm)
382
395
  architecture_summary = ArchitectureSummarizer(self.root).generate(sm)
383
396
 
@@ -436,13 +449,31 @@ class TaskContextBuilder:
436
449
  for p, n in sorted(counts2.items(), key=lambda x: -x[1])[:8]
437
450
  ]
438
451
 
452
+ # ── 5b. Git signals for ranking ────────────────────────────────────
453
+ git_hotspots: dict[str, int] = {}
454
+ uncommitted_files: set[str] = set()
455
+ try:
456
+ from sourcecode.git_analyzer import GitAnalyzer
457
+ _gc = GitAnalyzer().analyze(self.root, depth=30, days=90)
458
+ _bad = {"no_git_repo", "git_not_found", "git_timeout"}
459
+ if _gc and not (_bad & set(_gc.limitations)):
460
+ git_hotspots = {h.file: h.commit_count for h in _gc.change_hotspots}
461
+ if _gc.uncommitted_changes:
462
+ _uc = _gc.uncommitted_changes
463
+ uncommitted_files = set(_uc.staged) | set(_uc.unstaged)
464
+ except Exception:
465
+ pass
466
+
439
467
  # ── 6. Rank files ──────────────────────────────────────────────────
440
468
  entry_set = {ep.path for ep in entry_points}
441
469
  test_set = {p for p in all_paths if self._is_test(p)}
442
470
  source_set = {p for p in all_paths if not self._is_test(p) and self._is_source(p)}
443
471
 
444
472
  relevant_files = self._rank_files(
445
- task_name, spec, all_paths, entry_set, test_set
473
+ task_name, spec, all_paths, entry_set, test_set,
474
+ monorepo_packages=sm.monorepo_packages if sm.monorepo_packages else None,
475
+ git_hotspots=git_hotspots,
476
+ uncommitted_files=uncommitted_files,
446
477
  )
447
478
 
448
479
  # ── 7. Test gaps (generate-tests only) ────────────────────────────
@@ -590,7 +621,21 @@ class TaskContextBuilder:
590
621
  all_paths: list[str],
591
622
  entry_set: set[str],
592
623
  test_set: set[str],
624
+ monorepo_packages: Optional[list] = None,
625
+ git_hotspots: Optional[dict[str, int]] = None,
626
+ uncommitted_files: Optional[set[str]] = None,
593
627
  ) -> list[RelevantFile]:
628
+ from sourcecode.relevance_scorer import RelevanceScorer
629
+ scorer = RelevanceScorer(monorepo_packages or [])
630
+
631
+ # Auxiliary entry points (benchmark, docs, examples) must not get
632
+ # the production entry boost — they are not runtime signals.
633
+ runtime_entry_set = {ep for ep in entry_set if not scorer.is_auxiliary(ep)}
634
+
635
+ _hotspots = git_hotspots or {}
636
+ _uncommitted = uncommitted_files or set()
637
+ _max_churn = max(_hotspots.values(), default=1)
638
+
594
639
  scored: list[tuple[float, RelevantFile]] = []
595
640
 
596
641
  for path in all_paths:
@@ -599,6 +644,10 @@ class TaskContextBuilder:
599
644
  if any(pen in path for pen in spec.ranking_penalties):
600
645
  continue
601
646
 
647
+ # Hard filter: tooling/config noise
648
+ if scorer.is_noise(path):
649
+ continue
650
+
602
651
  is_test = path in test_set
603
652
  if is_test and task_name != "generate-tests":
604
653
  continue
@@ -606,7 +655,8 @@ class TaskContextBuilder:
606
655
  score = 0.0
607
656
  reasons: list[str] = []
608
657
 
609
- if path in entry_set:
658
+ # Only runtime entry points get the production boost
659
+ if path in runtime_entry_set:
610
660
  score += 3.0
611
661
  reasons.append("entry point")
612
662
 
@@ -625,11 +675,30 @@ class TaskContextBuilder:
625
675
  if not reasons:
626
676
  reasons.append("source file")
627
677
 
678
+ # Operational relevance boost/penalty from package role
679
+ rel = scorer.score(path)
680
+ score += (rel - 0.3) * 2.0 # center around 0.3 baseline
681
+
682
+ # Suppress auxiliary dirs (benchmarks, docs, examples, demos)
683
+ if scorer.is_auxiliary(path):
684
+ score -= 2.0
685
+
686
+ # Git churn: frequently changed files are high-signal for active work
687
+ churn = _hotspots.get(path, 0)
688
+ if churn > 0:
689
+ score += (churn / _max_churn) * 1.5
690
+ reasons.append(f"git churn ({churn})")
691
+
692
+ # Uncommitted changes: files actively being edited rank highest
693
+ if path in _uncommitted:
694
+ score += 1.0
695
+ reasons.append("uncommitted changes")
696
+
628
697
  if score <= 0:
629
698
  continue
630
699
 
631
700
  role = (
632
- "entrypoint" if path in entry_set
701
+ "entrypoint" if path in runtime_entry_set
633
702
  else ("test" if is_test else "source")
634
703
  )
635
704
  scored.append((score, RelevantFile(