sourcecode 0.22.0__py3-none-any.whl → 0.24.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 +1 -1
- sourcecode/architecture_summary.py +50 -0
- sourcecode/cli.py +37 -5
- sourcecode/confidence_analyzer.py +190 -0
- sourcecode/dependency_analyzer.py +51 -5
- sourcecode/detectors/csproj_parser.py +203 -0
- sourcecode/detectors/dotnet.py +148 -23
- sourcecode/detectors/heuristic.py +9 -1
- sourcecode/detectors/python.py +6 -0
- sourcecode/graph_analyzer.py +80 -0
- sourcecode/prepare_context.py +216 -13
- sourcecode/schema.py +30 -1
- sourcecode/serializer.py +108 -53
- {sourcecode-0.22.0.dist-info → sourcecode-0.24.0.dist-info}/METADATA +1 -1
- {sourcecode-0.22.0.dist-info → sourcecode-0.24.0.dist-info}/RECORD +17 -15
- {sourcecode-0.22.0.dist-info → sourcecode-0.24.0.dist-info}/WHEEL +0 -0
- {sourcecode-0.22.0.dist-info → sourcecode-0.24.0.dist-info}/entry_points.txt +0 -0
sourcecode/__init__.py
CHANGED
|
@@ -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]:
|
sourcecode/cli.py
CHANGED
|
@@ -623,6 +623,12 @@ def main(
|
|
|
623
623
|
for _finding in validate_cross_analyzer_consistency(sm, strict=False):
|
|
624
624
|
typer.echo(f"[consistency] {_finding}", err=True)
|
|
625
625
|
|
|
626
|
+
# Build confidence summary + analysis gaps (always runs, lightweight)
|
|
627
|
+
from sourcecode.confidence_analyzer import ConfidenceAnalyzer
|
|
628
|
+
from dataclasses import replace as _replace
|
|
629
|
+
_conf_summary, _analysis_gaps = ConfidenceAnalyzer().analyze(sm)
|
|
630
|
+
sm = _replace(sm, confidence_summary=_conf_summary, analysis_gaps=_analysis_gaps)
|
|
631
|
+
|
|
626
632
|
# 4. Serializar
|
|
627
633
|
if agent:
|
|
628
634
|
data = agent_view(sm)
|
|
@@ -666,13 +672,18 @@ def main(
|
|
|
666
672
|
def prepare_context_cmd(
|
|
667
673
|
task: Optional[str] = typer.Argument(
|
|
668
674
|
None,
|
|
669
|
-
help="Task: explain | fix-bug | refactor | generate-tests",
|
|
675
|
+
help="Task: explain | fix-bug | refactor | generate-tests | onboard | review-pr | delta",
|
|
670
676
|
),
|
|
671
677
|
path: Path = typer.Option(
|
|
672
678
|
Path("."),
|
|
673
679
|
"--path", "-p",
|
|
674
680
|
help="Project directory to analyze (default: current directory)",
|
|
675
681
|
),
|
|
682
|
+
since: Optional[str] = typer.Option(
|
|
683
|
+
None,
|
|
684
|
+
"--since",
|
|
685
|
+
help="Git ref for delta task: show files changed since this ref (e.g. HEAD~3, main)",
|
|
686
|
+
),
|
|
676
687
|
llm_prompt: bool = typer.Option(
|
|
677
688
|
False,
|
|
678
689
|
"--llm-prompt",
|
|
@@ -689,13 +700,24 @@ def prepare_context_cmd(
|
|
|
689
700
|
help="Show what would be analyzed without running it",
|
|
690
701
|
),
|
|
691
702
|
) -> None:
|
|
692
|
-
"""
|
|
703
|
+
"""Compile task-aware context for AI coding agents.
|
|
704
|
+
|
|
705
|
+
\b
|
|
706
|
+
Tasks:
|
|
707
|
+
explain Project overview: structure, entry points, dependencies
|
|
708
|
+
fix-bug Risk-ranked files, suspected areas, code annotations
|
|
709
|
+
refactor Structural issues, improvement opportunities
|
|
710
|
+
generate-tests Untested source files, test gap analysis
|
|
711
|
+
onboard Full project context for a new agent or developer
|
|
712
|
+
review-pr PR review context: changed files + architecture
|
|
713
|
+
delta Incremental context: git-changed files only
|
|
693
714
|
|
|
694
715
|
\b
|
|
695
|
-
|
|
716
|
+
Examples:
|
|
696
717
|
sourcecode . prepare-context explain
|
|
697
718
|
sourcecode . prepare-context fix-bug --path /my/project
|
|
698
|
-
sourcecode . prepare-context
|
|
719
|
+
sourcecode . prepare-context delta --since main
|
|
720
|
+
sourcecode . prepare-context onboard --llm-prompt
|
|
699
721
|
sourcecode . prepare-context --task-help
|
|
700
722
|
"""
|
|
701
723
|
from sourcecode.prepare_context import TASKS, TaskContextBuilder
|
|
@@ -734,22 +756,28 @@ def prepare_context_cmd(
|
|
|
734
756
|
typer.echo(f"path: {target}")
|
|
735
757
|
typer.echo(f"analyzers: dependencies={'yes' if spec.enable_dependencies else 'no'}"
|
|
736
758
|
f", code_notes={'yes' if spec.enable_code_notes else 'no'}")
|
|
759
|
+
if since:
|
|
760
|
+
typer.echo(f"since: {since}")
|
|
737
761
|
typer.echo(f"output: {spec.output_hint}")
|
|
738
762
|
raise typer.Exit()
|
|
739
763
|
|
|
740
764
|
from dataclasses import asdict
|
|
741
765
|
|
|
742
766
|
builder = TaskContextBuilder(target)
|
|
743
|
-
output = builder.build(task)
|
|
767
|
+
output = builder.build(task, since=since)
|
|
744
768
|
|
|
745
769
|
out: dict[str, Any] = {
|
|
746
770
|
"task": output.task,
|
|
747
771
|
"goal": output.goal,
|
|
748
772
|
"project_summary": output.project_summary,
|
|
749
773
|
"architecture_summary": output.architecture_summary,
|
|
774
|
+
"confidence": output.confidence,
|
|
750
775
|
"relevant_files": [asdict(f) for f in output.relevant_files],
|
|
776
|
+
"why_these_files": output.why_these_files,
|
|
751
777
|
"key_dependencies": output.key_dependencies,
|
|
752
778
|
}
|
|
779
|
+
if output.gaps:
|
|
780
|
+
out["gaps"] = output.gaps
|
|
753
781
|
if output.suspected_areas:
|
|
754
782
|
out["suspected_areas"] = output.suspected_areas
|
|
755
783
|
if output.improvement_opportunities:
|
|
@@ -758,6 +786,10 @@ def prepare_context_cmd(
|
|
|
758
786
|
out["test_gaps"] = output.test_gaps
|
|
759
787
|
if output.code_notes_summary:
|
|
760
788
|
out["code_notes_summary"] = output.code_notes_summary
|
|
789
|
+
if output.changed_files:
|
|
790
|
+
out["changed_files"] = output.changed_files
|
|
791
|
+
if output.affected_entry_points:
|
|
792
|
+
out["affected_entry_points"] = output.affected_entry_points
|
|
761
793
|
if output.limitations:
|
|
762
794
|
out["limitations"] = output.limitations
|
|
763
795
|
if llm_prompt:
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""confidence_analyzer.py — Builds ConfidenceSummary and AnalysisGap list from SourceMap.
|
|
2
|
+
|
|
3
|
+
Analyzes detection quality post-facto:
|
|
4
|
+
- Classifies signals as hard (manifest/lockfile) vs soft (heuristic/extension)
|
|
5
|
+
- Identifies auxiliary paths that were found but correctly ignored
|
|
6
|
+
- Detects anomalies (conflicting signals, low-confidence detections)
|
|
7
|
+
- Produces structured analysis gaps for agent consumption
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
from sourcecode.schema import AnalysisGap, ConfidenceSummary, SourceMap
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
_AUXILIARY_DIR_PREFIXES = (
|
|
21
|
+
".claude/", ".cursor/", ".vscode/", ".github/", ".idea/",
|
|
22
|
+
".devcontainer/", ".husky/",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
_FIXTURE_DIR_SEGMENTS = {"fixtures", "fixture", "testdata", "test_data", "__fixtures__"}
|
|
26
|
+
_TEST_DIR_SEGMENTS = {"tests", "test", "spec", "specs", "__tests__"}
|
|
27
|
+
_DOC_DIR_SEGMENTS = {"docs", "doc", "documentation", "wiki"}
|
|
28
|
+
_GENERATED_DIR_SEGMENTS = {"dist", "build", "target", "out", "output", ".next", "__pycache__"}
|
|
29
|
+
|
|
30
|
+
_HARD_SOURCES = {"manifest", "lockfile", "pyproject.toml", "package.json", "go.mod",
|
|
31
|
+
"Cargo.toml", "pom.xml", "build.gradle"}
|
|
32
|
+
_SOFT_SOURCES = {"heuristic", "code_signal", "convention"}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ConfidenceAnalyzer:
|
|
36
|
+
"""Analyzes SourceMap quality and produces confidence + gap metadata."""
|
|
37
|
+
|
|
38
|
+
def analyze(self, sm: SourceMap) -> tuple[ConfidenceSummary, list[AnalysisGap]]:
|
|
39
|
+
hard_signals: list[str] = []
|
|
40
|
+
soft_signals: list[str] = []
|
|
41
|
+
ignored_signals: list[str] = []
|
|
42
|
+
anomalies: list[str] = []
|
|
43
|
+
gaps: list[AnalysisGap] = []
|
|
44
|
+
|
|
45
|
+
# ── Stack signals ─────────────────────────────────────────────────────
|
|
46
|
+
for stack in sm.stacks:
|
|
47
|
+
if stack.detection_method == "manifest" and stack.confidence in ("high", "medium"):
|
|
48
|
+
for manifest in stack.manifests:
|
|
49
|
+
sig = f"stack:{stack.stack} via {manifest}"
|
|
50
|
+
if sig not in hard_signals:
|
|
51
|
+
hard_signals.append(sig)
|
|
52
|
+
elif stack.detection_method == "heuristic":
|
|
53
|
+
sig = f"stack:{stack.stack} (heuristic, no manifest)"
|
|
54
|
+
if sig not in soft_signals:
|
|
55
|
+
soft_signals.append(sig)
|
|
56
|
+
elif stack.detection_method == "lockfile":
|
|
57
|
+
sig = f"stack:{stack.stack} via lockfile"
|
|
58
|
+
if sig not in hard_signals:
|
|
59
|
+
hard_signals.append(sig)
|
|
60
|
+
|
|
61
|
+
# ── Entry point signals ───────────────────────────────────────────────
|
|
62
|
+
for ep in sm.entry_points:
|
|
63
|
+
if ep.source in _HARD_SOURCES or ep.reason == "console_script":
|
|
64
|
+
sig = f"entry:{ep.path} ({ep.reason or ep.source})"
|
|
65
|
+
if sig not in hard_signals:
|
|
66
|
+
hard_signals.append(sig)
|
|
67
|
+
else:
|
|
68
|
+
sig = f"entry:{ep.path} ({ep.reason or ep.source})"
|
|
69
|
+
if sig not in soft_signals:
|
|
70
|
+
soft_signals.append(sig)
|
|
71
|
+
|
|
72
|
+
# ── Ignored auxiliary paths ───────────────────────────────────────────
|
|
73
|
+
aux_dirs_found: set[str] = set()
|
|
74
|
+
for path in sm.file_paths:
|
|
75
|
+
norm = path.replace("\\", "/")
|
|
76
|
+
for prefix in _AUXILIARY_DIR_PREFIXES:
|
|
77
|
+
if norm.startswith(prefix):
|
|
78
|
+
top = prefix.rstrip("/")
|
|
79
|
+
aux_dirs_found.add(top)
|
|
80
|
+
break
|
|
81
|
+
|
|
82
|
+
for aux in sorted(aux_dirs_found):
|
|
83
|
+
ignored_signals.append(f"aux_dir:{aux} (tooling, not analyzed as project source)")
|
|
84
|
+
|
|
85
|
+
# ── Anomaly: multiple stacks, ambiguous primary ───────────────────────
|
|
86
|
+
primary_stacks = [s for s in sm.stacks if s.primary]
|
|
87
|
+
heuristic_only = [s for s in sm.stacks if s.detection_method == "heuristic"]
|
|
88
|
+
|
|
89
|
+
if len(primary_stacks) == 0 and sm.stacks:
|
|
90
|
+
anomalies.append("No primary stack marked — multiple stacks detected with equal weight")
|
|
91
|
+
if len(primary_stacks) > 1:
|
|
92
|
+
names = ", ".join(s.stack for s in primary_stacks)
|
|
93
|
+
anomalies.append(f"Multiple stacks marked as primary: {names}")
|
|
94
|
+
if heuristic_only and not any(s.detection_method != "heuristic" for s in sm.stacks):
|
|
95
|
+
anomalies.append("All stacks detected via heuristic only — no manifest found")
|
|
96
|
+
|
|
97
|
+
# ── Anomaly: entry points all low-confidence ──────────────────────────
|
|
98
|
+
if sm.entry_points and all(ep.confidence == "low" for ep in sm.entry_points):
|
|
99
|
+
anomalies.append("All entry points are low-confidence (heuristic/code_signal only)")
|
|
100
|
+
|
|
101
|
+
# ── Gaps ──────────────────────────────────────────────────────────────
|
|
102
|
+
if not sm.entry_points:
|
|
103
|
+
gaps.append(AnalysisGap(
|
|
104
|
+
area="entry_points",
|
|
105
|
+
reason="No entry point detected — project may use non-standard structure or be a library",
|
|
106
|
+
impact="high",
|
|
107
|
+
))
|
|
108
|
+
elif all(ep.confidence == "low" for ep in sm.entry_points):
|
|
109
|
+
gaps.append(AnalysisGap(
|
|
110
|
+
area="entry_points",
|
|
111
|
+
reason="Entry points inferred from code patterns only, no manifest declaration found",
|
|
112
|
+
impact="medium",
|
|
113
|
+
))
|
|
114
|
+
|
|
115
|
+
if not sm.stacks:
|
|
116
|
+
gaps.append(AnalysisGap(
|
|
117
|
+
area="stack",
|
|
118
|
+
reason="No stack detected — project may be infrastructure-only or use an unsupported language",
|
|
119
|
+
impact="high",
|
|
120
|
+
))
|
|
121
|
+
elif all(s.detection_method == "heuristic" for s in sm.stacks):
|
|
122
|
+
gaps.append(AnalysisGap(
|
|
123
|
+
area="stack",
|
|
124
|
+
reason="Stack inferred from file extensions only — no manifest or lockfile found",
|
|
125
|
+
impact="medium",
|
|
126
|
+
))
|
|
127
|
+
|
|
128
|
+
dep_summary = sm.dependency_summary
|
|
129
|
+
if dep_summary is None or not dep_summary.requested:
|
|
130
|
+
gaps.append(AnalysisGap(
|
|
131
|
+
area="dependencies",
|
|
132
|
+
reason="Dependencies not analyzed — run with --dependencies for full context",
|
|
133
|
+
impact="medium",
|
|
134
|
+
))
|
|
135
|
+
elif dep_summary.requested and dep_summary.total_count == 0:
|
|
136
|
+
gaps.append(AnalysisGap(
|
|
137
|
+
area="dependencies",
|
|
138
|
+
reason="No dependencies found — project may have no external dependencies or manifest is non-standard",
|
|
139
|
+
impact="low",
|
|
140
|
+
))
|
|
141
|
+
|
|
142
|
+
env_summary = sm.env_summary
|
|
143
|
+
if env_summary is None or not env_summary.requested:
|
|
144
|
+
gaps.append(AnalysisGap(
|
|
145
|
+
area="env",
|
|
146
|
+
reason="Environment variables not analyzed — run with --env-map for operational context",
|
|
147
|
+
impact="low",
|
|
148
|
+
))
|
|
149
|
+
|
|
150
|
+
# ── Compute overall confidence ─────────────────────────────────────────
|
|
151
|
+
# Stack: use best manifest-detected stack, fall back to min
|
|
152
|
+
manifest_stacks = [s for s in sm.stacks if s.detection_method != "heuristic"]
|
|
153
|
+
stack_conf = (
|
|
154
|
+
_max_confidence([s.confidence for s in manifest_stacks])
|
|
155
|
+
if manifest_stacks
|
|
156
|
+
else _min_confidence([s.confidence for s in sm.stacks] or ["low"])
|
|
157
|
+
)
|
|
158
|
+
# Entry points: use best available (highest-confidence EP wins)
|
|
159
|
+
ep_conf = _max_confidence([ep.confidence for ep in sm.entry_points] or ["low"])
|
|
160
|
+
overall = _min_confidence([stack_conf, ep_conf])
|
|
161
|
+
|
|
162
|
+
# Downgrade if gaps are severe
|
|
163
|
+
high_impact_gaps = [g for g in gaps if g.impact == "high"]
|
|
164
|
+
if high_impact_gaps:
|
|
165
|
+
overall = "low" if overall != "high" else "medium"
|
|
166
|
+
|
|
167
|
+
summary = ConfidenceSummary(
|
|
168
|
+
overall=overall, # type: ignore[arg-type]
|
|
169
|
+
stack_confidence=stack_conf, # type: ignore[arg-type]
|
|
170
|
+
entry_point_confidence=ep_conf, # type: ignore[arg-type]
|
|
171
|
+
hard_signals=hard_signals,
|
|
172
|
+
soft_signals=soft_signals,
|
|
173
|
+
ignored_signals=ignored_signals,
|
|
174
|
+
anomalies=anomalies,
|
|
175
|
+
)
|
|
176
|
+
return summary, gaps
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _min_confidence(values: list[str]) -> str:
|
|
180
|
+
rank = {"high": 2, "medium": 1, "low": 0}
|
|
181
|
+
if not values:
|
|
182
|
+
return "low"
|
|
183
|
+
return min(values, key=lambda v: rank.get(v, 0))
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _max_confidence(values: list[str]) -> str:
|
|
187
|
+
rank = {"high": 2, "medium": 1, "low": 0}
|
|
188
|
+
if not values:
|
|
189
|
+
return "low"
|
|
190
|
+
return max(values, key=lambda v: rank.get(v, 0))
|
|
@@ -32,6 +32,22 @@ _PY_PARSING: frozenset[str] = frozenset({
|
|
|
32
32
|
"pathspec", "gitpython", "lxml", "beautifulsoup4", "html5lib",
|
|
33
33
|
"regex", "pyparsing",
|
|
34
34
|
})
|
|
35
|
+
_PY_BUILDTOOLS: frozenset[str] = frozenset({
|
|
36
|
+
"setuptools", "hatchling", "flit-core", "flit", "wheel", "build", "twine",
|
|
37
|
+
"hatch", "poetry-core", "maturin", "cython",
|
|
38
|
+
})
|
|
39
|
+
_PY_OBSERVABILITY: frozenset[str] = frozenset({
|
|
40
|
+
"sentry-sdk", "datadog", "prometheus-client", "opentelemetry-api",
|
|
41
|
+
"opentelemetry-sdk", "structlog", "loguru", "elastic-apm", "ddtrace",
|
|
42
|
+
"opentelemetry-instrumentation",
|
|
43
|
+
})
|
|
44
|
+
_PY_INFRA: frozenset[str] = frozenset({
|
|
45
|
+
"boto3", "botocore", "azure-storage-blob", "azure-identity",
|
|
46
|
+
"google-cloud-storage", "google-cloud-bigquery", "kubernetes",
|
|
47
|
+
"paramiko", "fabric", "celery", "dramatiq", "redis", "aioredis",
|
|
48
|
+
"psycopg2", "psycopg2-binary", "asyncpg", "sqlalchemy", "motor",
|
|
49
|
+
"pymongo", "aiomysql", "mysql-connector-python",
|
|
50
|
+
})
|
|
35
51
|
|
|
36
52
|
_NODE_TESTTOOLS: frozenset[str] = frozenset({
|
|
37
53
|
"jest", "@jest/globals", "mocha", "jasmine", "chai", "karma",
|
|
@@ -39,8 +55,22 @@ _NODE_TESTTOOLS: frozenset[str] = frozenset({
|
|
|
39
55
|
"sinon", "nock", "supertest",
|
|
40
56
|
})
|
|
41
57
|
_NODE_DEVTOOLS: frozenset[str] = frozenset({
|
|
42
|
-
"eslint", "prettier", "typescript", "@types/node", "
|
|
43
|
-
"
|
|
58
|
+
"eslint", "prettier", "typescript", "@types/node", "husky",
|
|
59
|
+
"lint-staged", "nodemon", "ts-node", "tsx",
|
|
60
|
+
})
|
|
61
|
+
_NODE_BUILDTOOLS: frozenset[str] = frozenset({
|
|
62
|
+
"webpack", "webpack-cli", "rollup", "parcel", "@babel/core", "@babel/preset-env",
|
|
63
|
+
"esbuild", "@swc/core", "vite", "turbopack", "postcss", "autoprefixer",
|
|
64
|
+
"tailwindcss", "sass", "less",
|
|
65
|
+
})
|
|
66
|
+
_NODE_OBSERVABILITY: frozenset[str] = frozenset({
|
|
67
|
+
"@sentry/node", "dd-trace", "pino", "winston", "morgan",
|
|
68
|
+
"@opentelemetry/sdk-node", "pino-http",
|
|
69
|
+
})
|
|
70
|
+
_NODE_INFRA: frozenset[str] = frozenset({
|
|
71
|
+
"aws-sdk", "@aws-sdk/client-s3", "azure-storage", "firebase-admin",
|
|
72
|
+
"bull", "bullmq", "amqplib", "kafkajs", "ioredis", "pg", "mysql2",
|
|
73
|
+
"mongoose", "prisma", "@prisma/client", "typeorm", "sequelize",
|
|
44
74
|
})
|
|
45
75
|
|
|
46
76
|
_DEV_SCOPES: frozenset[str] = frozenset({"dev", "optional"})
|
|
@@ -49,13 +79,17 @@ _ROLE_PRIORITY: dict[str, int] = {
|
|
|
49
79
|
"runtime": 0,
|
|
50
80
|
"parsing": 1,
|
|
51
81
|
"serialization": 2,
|
|
52
|
-
"
|
|
53
|
-
"
|
|
82
|
+
"observability": 3,
|
|
83
|
+
"infra": 4,
|
|
84
|
+
"buildtool": 5,
|
|
85
|
+
"testtool": 6,
|
|
86
|
+
"devtool": 7,
|
|
87
|
+
"unknown": 8,
|
|
54
88
|
}
|
|
55
89
|
|
|
56
90
|
|
|
57
91
|
def _infer_role(name: str, ecosystem: str, scope: str) -> str:
|
|
58
|
-
"""Infer dependency role: runtime | parsing | serialization | devtool | testtool."""
|
|
92
|
+
"""Infer dependency role: runtime | parsing | serialization | buildtool | observability | infra | devtool | testtool | unknown."""
|
|
59
93
|
n = name.lower()
|
|
60
94
|
is_dev = scope in _DEV_SCOPES or scope.startswith(("optional:", "group:"))
|
|
61
95
|
|
|
@@ -64,8 +98,14 @@ def _infer_role(name: str, ecosystem: str, scope: str) -> str:
|
|
|
64
98
|
return "testtool"
|
|
65
99
|
if n in _PY_DEVTOOLS:
|
|
66
100
|
return "devtool"
|
|
101
|
+
if n in _PY_BUILDTOOLS:
|
|
102
|
+
return "buildtool"
|
|
67
103
|
if is_dev:
|
|
68
104
|
return "devtool"
|
|
105
|
+
if n in _PY_OBSERVABILITY:
|
|
106
|
+
return "observability"
|
|
107
|
+
if n in _PY_INFRA:
|
|
108
|
+
return "infra"
|
|
69
109
|
if n in _PY_SERIALIZATION:
|
|
70
110
|
return "serialization"
|
|
71
111
|
if n in _PY_PARSING:
|
|
@@ -77,8 +117,14 @@ def _infer_role(name: str, ecosystem: str, scope: str) -> str:
|
|
|
77
117
|
return "testtool"
|
|
78
118
|
if n in _NODE_DEVTOOLS:
|
|
79
119
|
return "devtool"
|
|
120
|
+
if n in _NODE_BUILDTOOLS:
|
|
121
|
+
return "buildtool"
|
|
80
122
|
if is_dev:
|
|
81
123
|
return "devtool"
|
|
124
|
+
if n in _NODE_OBSERVABILITY:
|
|
125
|
+
return "observability"
|
|
126
|
+
if n in _NODE_INFRA:
|
|
127
|
+
return "infra"
|
|
82
128
|
return "runtime"
|
|
83
129
|
|
|
84
130
|
return "devtool" if is_dev else "runtime"
|
|
@@ -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"
|