sourcecode 0.23.0__tar.gz → 0.24.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 (114) hide show
  1. {sourcecode-0.23.0 → sourcecode-0.24.0}/PKG-INFO +1 -1
  2. {sourcecode-0.23.0 → sourcecode-0.24.0}/docs/schema.md +2 -2
  3. {sourcecode-0.23.0 → sourcecode-0.24.0}/pyproject.toml +1 -1
  4. {sourcecode-0.23.0 → sourcecode-0.24.0}/src/sourcecode/__init__.py +1 -1
  5. {sourcecode-0.23.0 → sourcecode-0.24.0}/src/sourcecode/architecture_summary.py +50 -0
  6. sourcecode-0.24.0/src/sourcecode/detectors/csproj_parser.py +203 -0
  7. sourcecode-0.24.0/src/sourcecode/detectors/dotnet.py +182 -0
  8. {sourcecode-0.23.0 → sourcecode-0.24.0}/src/sourcecode/graph_analyzer.py +80 -0
  9. {sourcecode-0.23.0 → sourcecode-0.24.0}/src/sourcecode/schema.py +1 -1
  10. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/test_dependency_analyzer_node_python.py +1 -1
  11. sourcecode-0.24.0/tests/test_detector_dotnet.py +482 -0
  12. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/test_integration.py +1 -1
  13. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/test_integration_dependencies.py +1 -1
  14. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/test_schema.py +2 -2
  15. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/test_signal_hierarchy.py +1 -1
  16. sourcecode-0.23.0/src/sourcecode/detectors/dotnet.py +0 -57
  17. {sourcecode-0.23.0 → sourcecode-0.24.0}/.gitignore +0 -0
  18. {sourcecode-0.23.0 → sourcecode-0.24.0}/.ruff.toml +0 -0
  19. {sourcecode-0.23.0 → sourcecode-0.24.0}/README.md +0 -0
  20. {sourcecode-0.23.0 → sourcecode-0.24.0}/src/sourcecode/architecture_analyzer.py +0 -0
  21. {sourcecode-0.23.0 → sourcecode-0.24.0}/src/sourcecode/classifier.py +0 -0
  22. {sourcecode-0.23.0 → sourcecode-0.24.0}/src/sourcecode/cli.py +0 -0
  23. {sourcecode-0.23.0 → sourcecode-0.24.0}/src/sourcecode/code_notes_analyzer.py +0 -0
  24. {sourcecode-0.23.0 → sourcecode-0.24.0}/src/sourcecode/confidence_analyzer.py +0 -0
  25. {sourcecode-0.23.0 → sourcecode-0.24.0}/src/sourcecode/coverage_parser.py +0 -0
  26. {sourcecode-0.23.0 → sourcecode-0.24.0}/src/sourcecode/dependency_analyzer.py +0 -0
  27. {sourcecode-0.23.0 → sourcecode-0.24.0}/src/sourcecode/detectors/__init__.py +0 -0
  28. {sourcecode-0.23.0 → sourcecode-0.24.0}/src/sourcecode/detectors/base.py +0 -0
  29. {sourcecode-0.23.0 → sourcecode-0.24.0}/src/sourcecode/detectors/dart.py +0 -0
  30. {sourcecode-0.23.0 → sourcecode-0.24.0}/src/sourcecode/detectors/elixir.py +0 -0
  31. {sourcecode-0.23.0 → sourcecode-0.24.0}/src/sourcecode/detectors/go.py +0 -0
  32. {sourcecode-0.23.0 → sourcecode-0.24.0}/src/sourcecode/detectors/heuristic.py +0 -0
  33. {sourcecode-0.23.0 → sourcecode-0.24.0}/src/sourcecode/detectors/java.py +0 -0
  34. {sourcecode-0.23.0 → sourcecode-0.24.0}/src/sourcecode/detectors/jvm_ext.py +0 -0
  35. {sourcecode-0.23.0 → sourcecode-0.24.0}/src/sourcecode/detectors/nodejs.py +0 -0
  36. {sourcecode-0.23.0 → sourcecode-0.24.0}/src/sourcecode/detectors/parsers.py +0 -0
  37. {sourcecode-0.23.0 → sourcecode-0.24.0}/src/sourcecode/detectors/php.py +0 -0
  38. {sourcecode-0.23.0 → sourcecode-0.24.0}/src/sourcecode/detectors/project.py +0 -0
  39. {sourcecode-0.23.0 → sourcecode-0.24.0}/src/sourcecode/detectors/python.py +0 -0
  40. {sourcecode-0.23.0 → sourcecode-0.24.0}/src/sourcecode/detectors/ruby.py +0 -0
  41. {sourcecode-0.23.0 → sourcecode-0.24.0}/src/sourcecode/detectors/rust.py +0 -0
  42. {sourcecode-0.23.0 → sourcecode-0.24.0}/src/sourcecode/detectors/systems.py +0 -0
  43. {sourcecode-0.23.0 → sourcecode-0.24.0}/src/sourcecode/detectors/terraform.py +0 -0
  44. {sourcecode-0.23.0 → sourcecode-0.24.0}/src/sourcecode/detectors/tooling.py +0 -0
  45. {sourcecode-0.23.0 → sourcecode-0.24.0}/src/sourcecode/doc_analyzer.py +0 -0
  46. {sourcecode-0.23.0 → sourcecode-0.24.0}/src/sourcecode/env_analyzer.py +0 -0
  47. {sourcecode-0.23.0 → sourcecode-0.24.0}/src/sourcecode/git_analyzer.py +0 -0
  48. {sourcecode-0.23.0 → sourcecode-0.24.0}/src/sourcecode/metrics_analyzer.py +0 -0
  49. {sourcecode-0.23.0 → sourcecode-0.24.0}/src/sourcecode/prepare_context.py +0 -0
  50. {sourcecode-0.23.0 → sourcecode-0.24.0}/src/sourcecode/redactor.py +0 -0
  51. {sourcecode-0.23.0 → sourcecode-0.24.0}/src/sourcecode/scanner.py +0 -0
  52. {sourcecode-0.23.0 → sourcecode-0.24.0}/src/sourcecode/semantic_analyzer.py +0 -0
  53. {sourcecode-0.23.0 → sourcecode-0.24.0}/src/sourcecode/serializer.py +0 -0
  54. {sourcecode-0.23.0 → sourcecode-0.24.0}/src/sourcecode/summarizer.py +0 -0
  55. {sourcecode-0.23.0 → sourcecode-0.24.0}/src/sourcecode/tree_utils.py +0 -0
  56. {sourcecode-0.23.0 → sourcecode-0.24.0}/src/sourcecode/workspace.py +0 -0
  57. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/__init__.py +0 -0
  58. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/conftest.py +0 -0
  59. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/fixtures/coverage.xml +0 -0
  60. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/fixtures/fastapi_app/pyproject.toml +0 -0
  61. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/fixtures/fastapi_app/src/main.py +0 -0
  62. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/fixtures/go_service/cmd/api/main.go +0 -0
  63. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/fixtures/go_service/go.mod +0 -0
  64. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/fixtures/jacoco.xml +0 -0
  65. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/fixtures/lcov.info +0 -0
  66. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/fixtures/nextjs_app/app/page.tsx +0 -0
  67. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/fixtures/nextjs_app/package.json +0 -0
  68. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/fixtures/nextjs_app/pnpm-lock.yaml +0 -0
  69. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/fixtures/pnpm_monorepo/apps/web/app/page.tsx +0 -0
  70. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/fixtures/pnpm_monorepo/apps/web/package.json +0 -0
  71. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/fixtures/pnpm_monorepo/packages/api/main.py +0 -0
  72. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/fixtures/pnpm_monorepo/packages/api/pyproject.toml +0 -0
  73. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/fixtures/pnpm_monorepo/pnpm-workspace.yaml +0 -0
  74. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/test_architecture_analyzer.py +0 -0
  75. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/test_architecture_summary.py +0 -0
  76. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/test_classifier.py +0 -0
  77. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/test_cli.py +0 -0
  78. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/test_code_notes_analyzer.py +0 -0
  79. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/test_coverage_parser.py +0 -0
  80. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/test_cross_consistency.py +0 -0
  81. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/test_dependency_analyzer_polyglot.py +0 -0
  82. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/test_dependency_schema.py +0 -0
  83. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/test_detector_go_rust_java.py +0 -0
  84. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/test_detector_nodejs.py +0 -0
  85. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/test_detector_php_ruby_dart.py +0 -0
  86. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/test_detector_python.py +0 -0
  87. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/test_detector_universal_managed.py +0 -0
  88. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/test_detector_universal_systems.py +0 -0
  89. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/test_detectors_base.py +0 -0
  90. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/test_doc_analyzer_jsdom.py +0 -0
  91. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/test_doc_analyzer_python.py +0 -0
  92. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/test_graph_analyzer_polyglot.py +0 -0
  93. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/test_graph_analyzer_python_node.py +0 -0
  94. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/test_graph_schema.py +0 -0
  95. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/test_integration_detection.py +0 -0
  96. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/test_integration_docs.py +0 -0
  97. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/test_integration_graph_modules.py +0 -0
  98. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/test_integration_lqn.py +0 -0
  99. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/test_integration_metrics.py +0 -0
  100. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/test_integration_multistack.py +0 -0
  101. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/test_integration_semantics.py +0 -0
  102. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/test_integration_universal.py +0 -0
  103. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/test_metrics_analyzer.py +0 -0
  104. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/test_packaging.py +0 -0
  105. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/test_real_projects.py +0 -0
  106. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/test_redactor.py +0 -0
  107. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/test_scanner.py +0 -0
  108. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/test_schema_normalization.py +0 -0
  109. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/test_semantic_analyzer_node.py +0 -0
  110. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/test_semantic_analyzer_python.py +0 -0
  111. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/test_semantic_import_resolution.py +0 -0
  112. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/test_semantic_schema.py +0 -0
  113. {sourcecode-0.23.0 → sourcecode-0.24.0}/tests/test_summarizer.py +0 -0
  114. {sourcecode-0.23.0 → sourcecode-0.24.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.23.0
3
+ Version: 0.24.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.23.0",
84
+ "sourcecode_version": "0.24.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.23.0",
648
+ "sourcecode_version": "0.24.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.23.0"
7
+ version = "0.24.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.23.0"
3
+ __version__ = "0.24.0"
@@ -74,6 +74,8 @@ class ArchitectureSummarizer:
74
74
  lang_lines = self._summarize_go_entry(entry_point.path, content)
75
75
  elif suffix in _JAVA_EXTENSIONS:
76
76
  lang_lines = self._summarize_java_entry(entry_point.path, content, sm.stacks)
77
+ elif suffix in {".cs", ".fs", ".vb"}:
78
+ lang_lines = self._summarize_dotnet_entry(sm.stacks)
77
79
  else:
78
80
  lang_lines = []
79
81
 
@@ -189,6 +191,44 @@ class ArchitectureSummarizer:
189
191
  lines.append("Orquesta el arranque de la aplicacion JVM.")
190
192
  return lines
191
193
 
194
+ def _summarize_dotnet_entry(self, stacks: list[StackDetection]) -> list[str]:
195
+ dotnet_stacks = [s for s in stacks if s.stack == "dotnet"]
196
+ if not dotnet_stacks:
197
+ return []
198
+ lines: list[str] = []
199
+ signals = [sig for s in dotnet_stacks for sig in s.signals]
200
+
201
+ for sig in signals:
202
+ if "project" in sig and "detected" in sig:
203
+ lines.append(sig[0].upper() + sig[1:] + ".")
204
+ break
205
+
206
+ for sig in signals:
207
+ if sig.startswith("project types:"):
208
+ types = sig.removeprefix("project types:").strip()
209
+ lines.append(f"Stack: {types}.")
210
+ break
211
+
212
+ for sig in signals:
213
+ if sig.startswith("target frameworks:"):
214
+ fws = sig.removeprefix("target frameworks:").strip()
215
+ lines.append(f"Target: {fws}.")
216
+ break
217
+
218
+ for sig in signals:
219
+ if sig.startswith("architecture:"):
220
+ pattern = sig.removeprefix("architecture:").strip()
221
+ lines.append(f"Patrón detectado: {pattern}.")
222
+ break
223
+
224
+ framework_names = [f.name for s in dotnet_stacks for f in s.frameworks]
225
+ if framework_names:
226
+ lines.append(f"Frameworks: {', '.join(framework_names)}.")
227
+
228
+ if not lines:
229
+ lines.append("Solución .NET detectada.")
230
+ return lines
231
+
192
232
  def _summarize_go_entry(self, path: str, content: str) -> list[str]:
193
233
  imports = re.findall(r'"([^"]+)"', content)
194
234
  internal = [module for module in imports if not module.startswith(("fmt", "net/", "os", "context"))]
@@ -273,6 +313,16 @@ class ArchitectureSummarizer:
273
313
  confidence="medium",
274
314
  )
275
315
  )
316
+ elif path.endswith("Program.cs") or path.endswith("Program.fs"):
317
+ candidates.append(
318
+ EntryPoint(
319
+ path=path,
320
+ stack="dotnet",
321
+ kind="cli",
322
+ source="convention",
323
+ confidence="medium",
324
+ )
325
+ )
276
326
  return candidates
277
327
 
278
328
  def _fallback_priority(self, path: str) -> tuple[int, int, str]:
@@ -0,0 +1,203 @@
1
+ from __future__ import annotations
2
+
3
+ import xml.etree.ElementTree as ET
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path, PurePosixPath
6
+
7
+
8
+ @dataclass
9
+ class CsprojProject:
10
+ name: str
11
+ path: str # repo-relative path to .csproj
12
+ project_dir: str # repo-relative folder (empty if root)
13
+ target_frameworks: list[str] = field(default_factory=list)
14
+ output_type: str = ""
15
+ sdk: str = ""
16
+ project_references: list[str] = field(default_factory=list) # resolved repo-relative csproj paths
17
+ package_references: list[tuple[str, str]] = field(default_factory=list)
18
+ project_type: str = "classlib" # webapi|classlib|console|test|blazor|worker
19
+ language: str = "csharp" # csharp|fsharp|vbnet
20
+
21
+
22
+ _TEST_PACKAGES: frozenset[str] = frozenset({
23
+ "xunit", "nunit", "mstest.testframework", "microsoft.net.test.sdk",
24
+ "nunit3testadapter", "xunit.runner.visualstudio", "mstest.testadapter",
25
+ "coverlet.collector", "moq", "nsubstitute", "fluentassertions",
26
+ })
27
+
28
+ _LAYER_KEYWORDS: tuple[tuple[str, str], ...] = (
29
+ ("api", "Api"),
30
+ ("web", "Api"),
31
+ ("endpoint", "Api"),
32
+ ("controller", "Api"),
33
+ ("application", "Application"),
34
+ ("usecase", "Application"),
35
+ ("usecases", "Application"),
36
+ ("domain", "Domain"),
37
+ ("core", "Domain"),
38
+ ("infrastructure", "Infrastructure"),
39
+ ("infra", "Infrastructure"),
40
+ ("persistence", "Infrastructure"),
41
+ ("repository", "Infrastructure"),
42
+ ("shared", "Shared"),
43
+ ("common", "Shared"),
44
+ ("contracts", "Shared"),
45
+ ("abstractions", "Shared"),
46
+ ("test", "Tests"),
47
+ ("tests", "Tests"),
48
+ ("spec", "Tests"),
49
+ ("specs", "Tests"),
50
+ )
51
+
52
+
53
+ def parse_csproj(absolute_path: Path, relative_path: str) -> CsprojProject | None:
54
+ """Parse a .csproj/.fsproj/.vbproj and return structured project info. Returns None on error."""
55
+ try:
56
+ content = absolute_path.read_text(encoding="utf-8", errors="replace")
57
+ root_elem = ET.fromstring(content)
58
+ except (OSError, ET.ParseError):
59
+ return None
60
+
61
+ suffix = Path(relative_path).suffix.lower()
62
+ language = {"fsproj": "fsharp", "vbproj": "vbnet"}.get(suffix.lstrip("."), "csharp")
63
+
64
+ project_dir = str(PurePosixPath(relative_path).parent)
65
+ if project_dir == ".":
66
+ project_dir = ""
67
+
68
+ name = Path(relative_path).stem
69
+ sdk = root_elem.get("Sdk", "") or ""
70
+
71
+ frameworks: list[str] = []
72
+ output_type = ""
73
+ package_refs: list[tuple[str, str]] = []
74
+ project_refs: list[str] = []
75
+
76
+ for elem in root_elem.iter():
77
+ tag = _strip_ns(elem.tag)
78
+ text = (elem.text or "").strip()
79
+
80
+ if tag == "TargetFramework":
81
+ if not frameworks and text:
82
+ frameworks = [text]
83
+ elif tag == "TargetFrameworks":
84
+ if text:
85
+ frameworks = [f.strip() for f in text.split(";") if f.strip()]
86
+ elif tag == "OutputType":
87
+ if not output_type:
88
+ output_type = text
89
+ elif tag == "AssemblyName":
90
+ if text:
91
+ name = text
92
+ elif tag == "PackageReference":
93
+ pkg = elem.get("Include", "") or ""
94
+ ver = elem.get("Version", "") or (elem.findtext("Version") or "").strip()
95
+ if pkg:
96
+ package_refs.append((pkg, ver))
97
+ elif tag == "ProjectReference":
98
+ include = (elem.get("Include", "") or "").replace("\\", "/")
99
+ if include:
100
+ project_refs.append(include)
101
+
102
+ if not sdk:
103
+ sdk = _detect_sdk_from_imports(root_elem)
104
+
105
+ resolved_refs = [
106
+ r for r in (_resolve_ref(relative_path, ref) for ref in project_refs) if r
107
+ ]
108
+
109
+ project_type = _classify_project(
110
+ sdk=sdk,
111
+ output_type=output_type,
112
+ name=name,
113
+ package_refs=[p[0] for p in package_refs],
114
+ )
115
+
116
+ return CsprojProject(
117
+ name=name,
118
+ path=relative_path,
119
+ project_dir=project_dir,
120
+ target_frameworks=frameworks,
121
+ output_type=output_type,
122
+ sdk=sdk,
123
+ project_references=resolved_refs,
124
+ package_references=package_refs,
125
+ project_type=project_type,
126
+ language=language,
127
+ )
128
+
129
+
130
+ def infer_architecture_pattern(projects: list[CsprojProject]) -> str | None:
131
+ """Infer Clean Architecture / Onion / Layered from project names."""
132
+ layers: set[str] = set()
133
+ for project in projects:
134
+ name_lower = project.name.lower()
135
+ for keyword, layer in _LAYER_KEYWORDS:
136
+ if keyword in name_lower:
137
+ layers.add(layer)
138
+ break
139
+
140
+ if {"Application", "Domain", "Infrastructure"} <= layers:
141
+ return "Clean Architecture"
142
+ if {"Api", "Domain", "Infrastructure"} <= layers:
143
+ return "Onion Architecture"
144
+ if {"Api", "Application", "Infrastructure"} <= layers:
145
+ return "Layered Architecture"
146
+ if len(layers) >= 3:
147
+ return "Layered Architecture"
148
+ return None
149
+
150
+
151
+ def _strip_ns(tag: str) -> str:
152
+ return tag.split("}", 1)[1] if "}" in tag else tag
153
+
154
+
155
+ def _resolve_ref(source_csproj: str, ref: str) -> str | None:
156
+ """Resolve a ProjectReference path (relative to source .csproj) to a repo-relative path."""
157
+ source_dir = str(PurePosixPath(source_csproj).parent)
158
+ base = "" if source_dir == "." else source_dir
159
+ combined = f"{base}/{ref}" if base else ref
160
+ parts: list[str] = []
161
+ for part in combined.split("/"):
162
+ if part == "..":
163
+ if parts:
164
+ parts.pop()
165
+ elif part and part != ".":
166
+ parts.append(part)
167
+ return "/".join(parts) if parts else None
168
+
169
+
170
+ def _detect_sdk_from_imports(root_elem: ET.Element) -> str:
171
+ for elem in root_elem.iter():
172
+ if _strip_ns(elem.tag) == "Import":
173
+ project = elem.get("Project", "")
174
+ if "Microsoft.NET" in project:
175
+ return project
176
+ return ""
177
+
178
+
179
+ def _classify_project(
180
+ *,
181
+ sdk: str,
182
+ output_type: str,
183
+ name: str,
184
+ package_refs: list[str],
185
+ ) -> str:
186
+ sdk_l = sdk.lower()
187
+ name_l = name.lower()
188
+ pkgs_l = {p.lower() for p in package_refs}
189
+ out_l = output_type.lower()
190
+
191
+ if pkgs_l & _TEST_PACKAGES or any(kw in name_l for kw in ("test", "tests", "spec", "specs")):
192
+ return "test"
193
+ if "microsoft.net.sdk.blazor" in sdk_l or any("blazor" in p for p in pkgs_l):
194
+ return "blazor"
195
+ if "microsoft.net.sdk.web" in sdk_l or any(
196
+ p.startswith("microsoft.aspnetcore") for p in pkgs_l
197
+ ):
198
+ return "webapi"
199
+ if "microsoft.net.sdk.worker" in sdk_l or "microsoft.extensions.hosting" in pkgs_l:
200
+ return "worker"
201
+ if out_l in ("exe", "winexe"):
202
+ return "console"
203
+ return "classlib"
@@ -0,0 +1,182 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import Counter
4
+
5
+ from sourcecode.detectors.base import (
6
+ AbstractDetector,
7
+ DetectionContext,
8
+ EntryPoint,
9
+ StackDetection,
10
+ )
11
+ from sourcecode.detectors.csproj_parser import (
12
+ CsprojProject,
13
+ infer_architecture_pattern,
14
+ parse_csproj,
15
+ )
16
+ from sourcecode.schema import FrameworkDetection
17
+ from sourcecode.tree_utils import flatten_file_tree, path_exists_in_tree
18
+
19
+ _PROJECT_EXTENSIONS = (".csproj", ".fsproj", ".vbproj")
20
+
21
+ _FRAMEWORK_SIGNALS: tuple[tuple[str, str], ...] = (
22
+ ("microsoft.net.sdk.web", "ASP.NET Core"),
23
+ ("microsoft.net.sdk.blazor", "Blazor"),
24
+ ("microsoft.net.sdk.worker", "Worker Service"),
25
+ ("microsoft.aspnetcore", "ASP.NET Core"),
26
+ )
27
+
28
+ _ENTRY_CANDIDATES = (
29
+ "Program.cs",
30
+ "src/Program.cs",
31
+ "Program.fs",
32
+ "src/Program.fs",
33
+ "Startup.cs",
34
+ "src/Startup.cs",
35
+ )
36
+
37
+
38
+ class DotnetDetector(AbstractDetector):
39
+ name = "dotnet"
40
+ priority = 32
41
+
42
+ def can_detect(self, context: DetectionContext) -> bool:
43
+ return any(
44
+ path.endswith((*_PROJECT_EXTENSIONS, ".sln"))
45
+ for path in flatten_file_tree(context.file_tree)
46
+ )
47
+
48
+ def detect(self, context: DetectionContext) -> tuple[list[StackDetection], list[EntryPoint]]:
49
+ all_paths = flatten_file_tree(context.file_tree)
50
+ project_paths = [p for p in all_paths if p.endswith(_PROJECT_EXTENSIONS)]
51
+
52
+ projects: list[CsprojProject] = []
53
+ for rel_path in project_paths:
54
+ project = parse_csproj(context.root / rel_path, rel_path)
55
+ if project is not None:
56
+ projects.append(project)
57
+
58
+ frameworks = self._collect_frameworks(projects)
59
+ signals = self._build_signals(projects)
60
+ entry_points = self._detect_entry_points(context, projects, frameworks)
61
+
62
+ manifests = project_paths + [p for p in all_paths if p.endswith(".sln")]
63
+ stack = StackDetection(
64
+ stack="dotnet",
65
+ detection_method="manifest",
66
+ confidence="high",
67
+ frameworks=frameworks,
68
+ package_manager="nuget",
69
+ manifests=manifests,
70
+ signals=signals,
71
+ )
72
+ return [stack], entry_points
73
+
74
+ def _collect_frameworks(self, projects: list[CsprojProject]) -> list[FrameworkDetection]:
75
+ seen: dict[str, FrameworkDetection] = {}
76
+ for project in projects:
77
+ content_lower = project.sdk.lower()
78
+ for pkg_name, _ in project.package_references:
79
+ content_lower += " " + pkg_name.lower()
80
+ for signal, framework_name in _FRAMEWORK_SIGNALS:
81
+ if signal in content_lower and framework_name not in seen:
82
+ seen[framework_name] = FrameworkDetection(name=framework_name, source="manifest")
83
+ return list(seen.values())
84
+
85
+ def _build_signals(self, projects: list[CsprojProject]) -> list[str]:
86
+ if not projects:
87
+ return []
88
+ signals: list[str] = []
89
+
90
+ count = len(projects)
91
+ signals.append(f"{count} project{'s' if count != 1 else ''} detected")
92
+
93
+ type_counter: Counter[str] = Counter(p.project_type for p in projects)
94
+ type_parts = []
95
+ for ptype, n in type_counter.most_common():
96
+ type_parts.append(f"{ptype}×{n}" if n > 1 else ptype)
97
+ signals.append(f"project types: {', '.join(type_parts)}")
98
+
99
+ frameworks_seen: set[str] = set()
100
+ all_frameworks: list[str] = []
101
+ for project in projects:
102
+ for fw in project.target_frameworks:
103
+ if fw and fw not in frameworks_seen:
104
+ frameworks_seen.add(fw)
105
+ all_frameworks.append(fw)
106
+ if all_frameworks:
107
+ signals.append(f"target frameworks: {', '.join(all_frameworks[:4])}")
108
+
109
+ pattern = infer_architecture_pattern(projects)
110
+ if pattern:
111
+ signals.append(f"architecture: {pattern}")
112
+
113
+ return signals
114
+
115
+ def _detect_entry_points(
116
+ self,
117
+ context: DetectionContext,
118
+ projects: list[CsprojProject],
119
+ frameworks: list[FrameworkDetection],
120
+ ) -> list[EntryPoint]:
121
+ entry_points: list[EntryPoint] = []
122
+ framework_names = {f.name for f in frameworks}
123
+ has_web = bool(framework_names & {"ASP.NET Core", "Blazor"})
124
+
125
+ # Entry points from exe/web projects
126
+ for project in projects:
127
+ if project.project_type in ("webapi", "console", "worker", "blazor"):
128
+ kind = _project_type_to_entry_kind(project.project_type, has_web)
129
+ # Look for Program.cs/Startup.cs relative to the project dir
130
+ for candidate in _entry_file_candidates(project):
131
+ if path_exists_in_tree(context.file_tree, candidate):
132
+ entry_points.append(
133
+ EntryPoint(
134
+ path=candidate,
135
+ stack="dotnet",
136
+ kind=kind,
137
+ source="manifest",
138
+ confidence="high",
139
+ )
140
+ )
141
+ break
142
+
143
+ # Fallback: known entry file patterns
144
+ if not entry_points:
145
+ kind = "api" if has_web else "cli"
146
+ for candidate in _ENTRY_CANDIDATES:
147
+ if path_exists_in_tree(context.file_tree, candidate):
148
+ entry_points.append(
149
+ EntryPoint(
150
+ path=candidate,
151
+ stack="dotnet",
152
+ kind=kind,
153
+ source="manifest",
154
+ confidence="medium",
155
+ )
156
+ )
157
+ break
158
+
159
+ return entry_points
160
+
161
+
162
+ def _project_type_to_entry_kind(project_type: str, has_web: bool) -> str:
163
+ if project_type == "webapi":
164
+ return "api"
165
+ if project_type == "blazor":
166
+ return "web"
167
+ if project_type == "worker":
168
+ return "server"
169
+ return "cli"
170
+
171
+
172
+ def _entry_file_candidates(project: CsprojProject) -> list[str]:
173
+ prefix = f"{project.project_dir}/" if project.project_dir else ""
174
+ candidates = [
175
+ f"{prefix}Program.cs",
176
+ f"{prefix}Program.fs",
177
+ f"{prefix}Startup.cs",
178
+ ]
179
+ # Also check without prefix for root-level projects
180
+ if prefix:
181
+ candidates += ["Program.cs", "src/Program.cs"]
182
+ return candidates
@@ -21,6 +21,7 @@ class GraphAnalyzer:
21
21
  _NODE_EXTENSIONS = {".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"}
22
22
  _GO_EXTENSIONS = {".go"}
23
23
  _JVM_EXTENSIONS = {".java", ".kt", ".scala"}
24
+ _DOTNET_PROJECT_EXTENSIONS = {".csproj", ".fsproj", ".vbproj"}
24
25
  _SUPPORTED_EXTENSIONS = _PYTHON_EXTENSIONS | _NODE_EXTENSIONS | _GO_EXTENSIONS | _JVM_EXTENSIONS
25
26
  _DEFAULT_EDGE_KINDS: dict[GraphDetail, tuple[str, ...]] = {
26
27
  "high": ("imports",),
@@ -167,6 +168,23 @@ class GraphAnalyzer:
167
168
  go_imports = self._build_go_import_map(root, source_files)
168
169
  jvm_types = self._build_jvm_type_map(root, source_files)
169
170
 
171
+ # .NET: project-level graph from .csproj/.fsproj/.vbproj manifests
172
+ all_tree_paths = [p for p in flatten_file_tree(file_tree)]
173
+ dotnet_project_paths = [
174
+ p for p in all_tree_paths
175
+ if Path(p).suffix.lower() in self._DOTNET_PROJECT_EXTENSIONS
176
+ and (root / p).is_file()
177
+ ]
178
+ if dotnet_project_paths:
179
+ dn_nodes, dn_edges, dn_lims = self._analyze_dotnet_projects(
180
+ root, dotnet_project_paths, workspace
181
+ )
182
+ for node in dn_nodes:
183
+ self._append_node(nodes, node_ids, node)
184
+ for edge in dn_edges:
185
+ self._append_edge(edges, edge_keys, edge)
186
+ limitations.extend(dn_lims)
187
+
170
188
  for relative_path in source_files:
171
189
  absolute_path = root / relative_path
172
190
  try:
@@ -1144,6 +1162,62 @@ class GraphAnalyzer:
1144
1162
  edge_keys.add(key)
1145
1163
  edges.append(edge)
1146
1164
 
1165
+ def _analyze_dotnet_projects(
1166
+ self,
1167
+ root: Path,
1168
+ csproj_paths: list[str],
1169
+ workspace: str | None,
1170
+ ) -> tuple[list[GraphNode], list[GraphEdge], list[str]]:
1171
+ from sourcecode.detectors.csproj_parser import parse_csproj
1172
+
1173
+ nodes: list[GraphNode] = []
1174
+ edges: list[GraphEdge] = []
1175
+ limitations: list[str] = []
1176
+
1177
+ projects = []
1178
+ node_id_map: dict[str, str] = {} # csproj_path → node_id
1179
+
1180
+ for rel_path in csproj_paths:
1181
+ project = parse_csproj(root / rel_path, rel_path)
1182
+ if project is None:
1183
+ limitations.append(f"dotnet_parse_error:{rel_path}")
1184
+ continue
1185
+ projects.append(project)
1186
+ node_path = project.project_dir if project.project_dir else project.name
1187
+ node_id = f"module:{node_path}"
1188
+ node_id_map[project.path] = node_id
1189
+ nodes.append(
1190
+ GraphNode(
1191
+ id=node_id,
1192
+ kind="module",
1193
+ language=project.language,
1194
+ path=node_path,
1195
+ display_name=project.name,
1196
+ workspace=workspace,
1197
+ )
1198
+ )
1199
+
1200
+ for project in projects:
1201
+ source_id = node_id_map.get(project.path)
1202
+ if source_id is None:
1203
+ continue
1204
+ for ref_path in project.project_references:
1205
+ target_id = node_id_map.get(ref_path)
1206
+ if target_id is None:
1207
+ limitations.append(f"dotnet_unresolved_ref:{project.path}:{ref_path}")
1208
+ continue
1209
+ edges.append(
1210
+ GraphEdge(
1211
+ source=source_id,
1212
+ target=target_id,
1213
+ kind="imports",
1214
+ confidence="high",
1215
+ method="heuristic",
1216
+ )
1217
+ )
1218
+
1219
+ return nodes, edges, limitations
1220
+
1147
1221
  def _language_for_suffix(self, suffix: str) -> str:
1148
1222
  if suffix in self._PYTHON_EXTENSIONS:
1149
1223
  return "python"
@@ -1157,6 +1231,12 @@ class GraphAnalyzer:
1157
1231
  return "kotlin"
1158
1232
  if suffix == ".scala":
1159
1233
  return "scala"
1234
+ if suffix == ".cs":
1235
+ return "csharp"
1236
+ if suffix == ".fs":
1237
+ return "fsharp"
1238
+ if suffix == ".vb":
1239
+ return "vbnet"
1160
1240
  return "unknown"
1161
1241
 
1162
1242
  def _node_id(self, kind: str, path: str, symbol: str | None = None) -> str:
@@ -527,6 +527,6 @@ class SourceMap:
527
527
  code_notes: list[CodeNote] = field(default_factory=list)
528
528
  code_adrs: list[AdrRecord] = field(default_factory=list)
529
529
  code_notes_summary: Optional[CodeNotesSummary] = None
530
- # Confidence & Explainability (v0.23.0)
530
+ # Confidence & Explainability (v0.24.0)
531
531
  confidence_summary: Optional[ConfidenceSummary] = None
532
532
  analysis_gaps: list[AnalysisGap] = field(default_factory=list)
@@ -106,6 +106,6 @@ def test_python_requirements_without_lockfile_keeps_declared_versions(tmp_path:
106
106
  assert summary.direct_count == 2
107
107
  typer_dep = next(record for record in records if record.name == "typer")
108
108
  rich_dep = next(record for record in records if record.name == "rich")
109
- assert typer_dep.declared_version == "==0.23.0"
109
+ assert typer_dep.declared_version == "==0.24.0"
110
110
  assert typer_dep.resolved_version is None
111
111
  assert rich_dep.declared_version == ">=13.0"