sourcecode 2.1.0__py3-none-any.whl → 2.3.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/archetype.py +605 -0
- sourcecode/architecture_summary.py +41 -0
- sourcecode/caller_metrics.py +42 -0
- sourcecode/classifier.py +94 -1
- sourcecode/cli.py +191 -29
- sourcecode/detectors/java.py +29 -1
- sourcecode/endpoint_metrics.py +42 -0
- sourcecode/explain.py +2 -0
- sourcecode/graph_evidence.py +352 -0
- sourcecode/path_filters.py +2 -1
- sourcecode/repository_ir.py +50 -14
- sourcecode/semantic_integration_engine.py +18 -2
- sourcecode/serializer.py +23 -3
- sourcecode/spring_semantic.py +18 -5
- sourcecode/spring_tx_analyzer.py +21 -6
- {sourcecode-2.1.0.dist-info → sourcecode-2.3.0.dist-info}/METADATA +4 -4
- {sourcecode-2.1.0.dist-info → sourcecode-2.3.0.dist-info}/RECORD +21 -17
- {sourcecode-2.1.0.dist-info → sourcecode-2.3.0.dist-info}/WHEEL +0 -0
- {sourcecode-2.1.0.dist-info → sourcecode-2.3.0.dist-info}/entry_points.txt +0 -0
- {sourcecode-2.1.0.dist-info → sourcecode-2.3.0.dist-info}/licenses/LICENSE +0 -0
sourcecode/__init__.py
CHANGED
sourcecode/archetype.py
ADDED
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
"""Evidence-based architectural archetype classifier (ADDITIVE / EXPERIMENTAL).
|
|
2
|
+
|
|
3
|
+
Runs in PARALLEL with the legacy `ArchitectureAnalyzer` pattern. It does NOT feed
|
|
4
|
+
the report wording yet — it exists so the new model can be validated against the
|
|
5
|
+
legacy one across many repos before any cutover.
|
|
6
|
+
|
|
7
|
+
Design goals (vs the legacy directory-name matcher):
|
|
8
|
+
* describe WHAT a system is, not which technologies it contains;
|
|
9
|
+
* accumulate weighted evidence from many signals — never a single dominant rule;
|
|
10
|
+
* weight every signal by CODE MASS (fraction of the codebase it represents), so a
|
|
11
|
+
directory named `api/` in a 5,600-file engine contributes ~0.003, not 1.0;
|
|
12
|
+
* report per-candidate score + evidence + confidence + a human explanation.
|
|
13
|
+
|
|
14
|
+
Four ORTHOGONAL dimensions (a system is a point in each, not one enum):
|
|
15
|
+
* software_archetype : engine | platform | framework | library | application | compiler | sdk
|
|
16
|
+
* architectural_style: layered | hexagonal | clean | mvc | event-driven | plugin-based | microkernel
|
|
17
|
+
* deployment_shape : monolith | multi-module | distributed | embedded
|
|
18
|
+
* primary_interface : api-first | cli | worker | daemon | library | embedded
|
|
19
|
+
|
|
20
|
+
Signals consumed today (all cheap / already computed by a normal scan):
|
|
21
|
+
module masses (file_tree), package-concept masses (path segments), entry-point
|
|
22
|
+
kinds, HTTP endpoint share, packaging, detected frameworks, and — when present on
|
|
23
|
+
the SourceMap — module_graph_summary (hubs/cycles). Richer signals (repo-ir
|
|
24
|
+
subsystem masses, Semantic-IR SPI fan-in, protocol handlers) are declared in
|
|
25
|
+
`signals_missing` when unavailable so the confidence reflects it.
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Any, Optional
|
|
32
|
+
|
|
33
|
+
from sourcecode.graph_evidence import GraphEvidence, GraphEvidenceProvider, GraphEvidenceResult
|
|
34
|
+
from sourcecode.schema import SourceMap
|
|
35
|
+
from sourcecode.tree_utils import flatten_file_tree
|
|
36
|
+
|
|
37
|
+
_JVM_EXTS = (".java", ".kt", ".kts", ".scala", ".groovy")
|
|
38
|
+
_CODE_EXTS = _JVM_EXTS + (".py", ".go", ".rs", ".cs", ".ts", ".js", ".rb", ".php")
|
|
39
|
+
_NON_SOURCE = frozenset({
|
|
40
|
+
"test", "tests", "src-test", "it", "benchmark", "benchmarks", "bench",
|
|
41
|
+
"example", "examples", "demo", "docs", "doc", "fixture", "fixtures",
|
|
42
|
+
"node_modules", "target", "build", "generated", "resources",
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
# Concept banks: path-segment keywords → a named concept. Mass of a concept =
|
|
46
|
+
# fraction of source files whose path contains any of its segments. These are
|
|
47
|
+
# DOMAIN concepts, not framework names; membership is deliberately broad.
|
|
48
|
+
_CONCEPTS: dict[str, frozenset[str]] = {
|
|
49
|
+
"storage": frozenset({"storage", "store", "pagecache", "pagecache", "recordstorage",
|
|
50
|
+
"record", "btree", "gbptree", "wal", "log", "tx", "transaction",
|
|
51
|
+
"commit", "checkpoint", "index", "lucene", "kvstore", "heap"}),
|
|
52
|
+
"kernel": frozenset({"kernel", "engine", "executor", "runtime", "core", "internal"}),
|
|
53
|
+
"compiler": frozenset({"parser", "lexer", "ast", "grammar", "antlr", "codegen",
|
|
54
|
+
"compiler", "ir", "bytecode", "semantic", "typecheck", "rewriter",
|
|
55
|
+
"planner", "optimizer", "expression"}),
|
|
56
|
+
"protocol": frozenset({"protocol", "bolt", "netty", "transport", "wire", "packstream",
|
|
57
|
+
"rpc", "grpc", "http", "connector", "connection"}),
|
|
58
|
+
"distributed": frozenset({"cluster", "raft", "discovery", "gossip", "consensus",
|
|
59
|
+
"replication", "coordination", "membership", "fabric", "routing"}),
|
|
60
|
+
"web": frozenset({"controller", "controllers", "rest", "mvc", "servlet",
|
|
61
|
+
"endpoint", "endpoints", "resource", "webapp", "jaxrs"}),
|
|
62
|
+
"service": frozenset({"service", "services", "usecase", "usecases", "application"}),
|
|
63
|
+
"repository": frozenset({"repository", "repositories", "repo", "dao", "persistence"}),
|
|
64
|
+
"event": frozenset({"event", "events", "listener", "listeners", "publisher",
|
|
65
|
+
"subscriber", "bus", "dispatcher", "reactor"}),
|
|
66
|
+
"plugin": frozenset({"plugin", "plugins", "extension", "extensions", "spi",
|
|
67
|
+
"provider", "providers", "addon", "module"}),
|
|
68
|
+
"api_pkg": frozenset({"api", "apis", "spi", "contract", "interfaces"}),
|
|
69
|
+
"cli": frozenset({"cli", "command", "commands", "commandline", "shell", "console"}),
|
|
70
|
+
"hexagonal": frozenset({"adapter", "adapters", "port", "ports"}),
|
|
71
|
+
"clean": frozenset({"domain", "domains", "usecase", "usecases", "infrastructure", "infra"}),
|
|
72
|
+
"cqrs": frozenset({"command", "commands", "query", "queries"}),
|
|
73
|
+
"sdk_client": frozenset({"client", "clients", "sdk"}),
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
_CONF_ORDER = {"high": 3, "medium": 2, "low": 1}
|
|
77
|
+
# A dimension whose best candidate scores below this is reported as undetermined
|
|
78
|
+
# (primary=None) rather than asserting a label on noise.
|
|
79
|
+
_MIN_PRIMARY_SCORE = 0.05
|
|
80
|
+
# A winner must clear this absolute score (not just the top-2 margin) to earn
|
|
81
|
+
# medium/high confidence — a near-floor lone candidate stays "low".
|
|
82
|
+
_MIN_CONFIDENT_SCORE = 0.5
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class Evidence:
|
|
87
|
+
signal: str # machine name, e.g. "storage_mass"
|
|
88
|
+
detail: str # human-readable
|
|
89
|
+
weight: float # base importance of the signal
|
|
90
|
+
strength: float # 0..1 — how strongly the signal is present
|
|
91
|
+
mass: float # 0..1 — fraction of the codebase it represents (1.0 if N/A)
|
|
92
|
+
contribution: float # weight * strength * mass (what actually adds to the score)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class ArchetypeCandidate:
|
|
97
|
+
label: str
|
|
98
|
+
score: float
|
|
99
|
+
evidence: list[Evidence] = field(default_factory=list)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass
|
|
103
|
+
class DimensionResult:
|
|
104
|
+
dimension: str
|
|
105
|
+
primary: Optional[str]
|
|
106
|
+
confidence: str # high | medium | low
|
|
107
|
+
candidates: list[ArchetypeCandidate] # sorted score desc
|
|
108
|
+
explanation: str
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass
|
|
112
|
+
class ArchetypeAnalysis:
|
|
113
|
+
dimensions: dict[str, DimensionResult]
|
|
114
|
+
signals_used: list[str]
|
|
115
|
+
signals_missing: list[str]
|
|
116
|
+
generated_from: str
|
|
117
|
+
graph_metrics: dict[str, float] = field(default_factory=dict)
|
|
118
|
+
|
|
119
|
+
def to_dict(self) -> dict[str, Any]:
|
|
120
|
+
return {
|
|
121
|
+
"schema_version": "0.1-experimental",
|
|
122
|
+
"note": "Parallel/experimental model. Legacy architecture pattern is unchanged.",
|
|
123
|
+
"dimensions": {
|
|
124
|
+
dim: {
|
|
125
|
+
"primary": r.primary,
|
|
126
|
+
"confidence": r.confidence,
|
|
127
|
+
"explanation": r.explanation,
|
|
128
|
+
"candidates": [
|
|
129
|
+
{
|
|
130
|
+
"label": c.label,
|
|
131
|
+
"score": round(c.score, 4),
|
|
132
|
+
"evidence": [
|
|
133
|
+
{
|
|
134
|
+
"signal": e.signal, "detail": e.detail,
|
|
135
|
+
"weight": e.weight, "strength": round(e.strength, 3),
|
|
136
|
+
"mass": round(e.mass, 4),
|
|
137
|
+
"contribution": round(e.contribution, 4),
|
|
138
|
+
}
|
|
139
|
+
for e in c.evidence
|
|
140
|
+
],
|
|
141
|
+
}
|
|
142
|
+
for c in r.candidates
|
|
143
|
+
],
|
|
144
|
+
}
|
|
145
|
+
for dim, r in self.dimensions.items()
|
|
146
|
+
},
|
|
147
|
+
"signals_used": self.signals_used,
|
|
148
|
+
"signals_missing": self.signals_missing,
|
|
149
|
+
"generated_from": self.generated_from,
|
|
150
|
+
"graph_metrics": {k: round(v, 4) for k, v in self.graph_metrics.items()},
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass
|
|
155
|
+
class _Features:
|
|
156
|
+
total_files: int
|
|
157
|
+
module_counts: dict[str, int]
|
|
158
|
+
dominant_module: Optional[str]
|
|
159
|
+
dominant_share: float
|
|
160
|
+
module_count: int
|
|
161
|
+
concept_mass: dict[str, float] # concept → fraction of source files
|
|
162
|
+
entry_kinds: dict[str, int]
|
|
163
|
+
endpoint_total: int
|
|
164
|
+
endpoint_share: float # endpoints per source file
|
|
165
|
+
packaging: Optional[str]
|
|
166
|
+
frameworks: set[str]
|
|
167
|
+
graph_hubs: list[str]
|
|
168
|
+
graph_cycles: int
|
|
169
|
+
graph_available: bool
|
|
170
|
+
graph_evidence: list[GraphEvidence]
|
|
171
|
+
graph_metrics: dict[str, float]
|
|
172
|
+
signals_used: list[str]
|
|
173
|
+
signals_missing: list[str]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# Which archetype each topological metric supports, and how. Each entry is
|
|
177
|
+
# (dimension, label, weight, polarity, lo, hi): the metric's raw value is ramped
|
|
178
|
+
# to a strength via (value-lo)/(hi-lo) clamped to [0,1]; polarity +1 supports the
|
|
179
|
+
# label, -1 refutes it (negative contribution). This is the ONLY place that knows
|
|
180
|
+
# both graphs and archetypes — the provider stays archetype-agnostic, the scorer
|
|
181
|
+
# stays graph-agnostic, and the coupling lives here as declarative data.
|
|
182
|
+
_GRAPH_MAP: dict[str, list[tuple[str, str, float, int, float, float]]] = {
|
|
183
|
+
"scc_mass": [
|
|
184
|
+
("software_archetype", "engine", 2.5, +1, 0.10, 0.50),
|
|
185
|
+
("architectural_style", "layered", 1.5, -1, 0.10, 0.50),
|
|
186
|
+
],
|
|
187
|
+
"cyclic_density": [
|
|
188
|
+
("software_archetype", "engine", 2.0, +1, 0.05, 0.40),
|
|
189
|
+
("architectural_style", "layered", 2.0, -1, 0.05, 0.40),
|
|
190
|
+
("architectural_style", "hexagonal", 1.5, -1, 0.05, 0.40),
|
|
191
|
+
("architectural_style", "microkernel", 1.5, -1, 0.05, 0.40),
|
|
192
|
+
],
|
|
193
|
+
"hub_concentration": [
|
|
194
|
+
("software_archetype", "engine", 1.5, +1, 0.10, 0.40),
|
|
195
|
+
("architectural_style", "microkernel", 1.5, +1, 0.10, 0.40),
|
|
196
|
+
],
|
|
197
|
+
"module_centralization": [
|
|
198
|
+
("architectural_style", "microkernel", 2.0, +1, 0.30, 0.80),
|
|
199
|
+
("architectural_style", "hexagonal", 1.5, -1, 0.30, 0.80), # a single hub is not a distributed domain core
|
|
200
|
+
("software_archetype", "platform", 0.8, +1, 0.30, 0.80),
|
|
201
|
+
],
|
|
202
|
+
"downward_edge_ratio": [
|
|
203
|
+
("architectural_style", "layered", 2.5, +1, 0.55, 0.90),
|
|
204
|
+
("architectural_style", "hexagonal", 1.0, +1, 0.55, 0.90),
|
|
205
|
+
],
|
|
206
|
+
"dependency_inversion_ratio": [
|
|
207
|
+
("architectural_style", "hexagonal", 2.5, +1, 0.50, 0.80),
|
|
208
|
+
("architectural_style", "clean", 1.5, +1, 0.50, 0.80),
|
|
209
|
+
],
|
|
210
|
+
"fan_in_gini": [
|
|
211
|
+
("software_archetype", "engine", 1.5, +1, 0.40, 0.75),
|
|
212
|
+
],
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class ArchetypeClassifier:
|
|
217
|
+
"""Consumes a SourceMap; emits an ArchetypeAnalysis. Pure (no filesystem writes)."""
|
|
218
|
+
|
|
219
|
+
def analyze(
|
|
220
|
+
self,
|
|
221
|
+
sm: SourceMap,
|
|
222
|
+
root: Optional[Path] = None,
|
|
223
|
+
module_graph: object = None,
|
|
224
|
+
) -> ArchetypeAnalysis:
|
|
225
|
+
f = self._extract_features(sm, root, module_graph)
|
|
226
|
+
dims = {
|
|
227
|
+
"software_archetype": self._software_archetype(f),
|
|
228
|
+
"architectural_style": self._architectural_style(f),
|
|
229
|
+
"deployment_shape": self._deployment_shape(f),
|
|
230
|
+
"primary_interface": self._primary_interface(f),
|
|
231
|
+
}
|
|
232
|
+
return ArchetypeAnalysis(
|
|
233
|
+
dimensions=dims,
|
|
234
|
+
signals_used=f.signals_used,
|
|
235
|
+
signals_missing=f.signals_missing,
|
|
236
|
+
generated_from=(
|
|
237
|
+
f"{f.total_files} source files, {f.module_count} modules, "
|
|
238
|
+
f"{f.endpoint_total} endpoints"
|
|
239
|
+
),
|
|
240
|
+
graph_metrics=f.graph_metrics,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
# ── feature extraction ────────────────────────────────────────────────────
|
|
244
|
+
def _extract_features(
|
|
245
|
+
self, sm: SourceMap, root: Optional[Path], module_graph: object = None,
|
|
246
|
+
) -> _Features:
|
|
247
|
+
used: list[str] = []
|
|
248
|
+
missing: list[str] = []
|
|
249
|
+
paths = [p.replace("\\", "/") for p in (sm.file_paths or flatten_file_tree(sm.file_tree))]
|
|
250
|
+
src = [
|
|
251
|
+
p for p in paths
|
|
252
|
+
if p.endswith(_CODE_EXTS)
|
|
253
|
+
and not any(seg.lower() in _NON_SOURCE for seg in p.split("/")[:-1])
|
|
254
|
+
]
|
|
255
|
+
total = len(src)
|
|
256
|
+
|
|
257
|
+
module_counts: dict[str, int] = {}
|
|
258
|
+
for p in src:
|
|
259
|
+
module_counts[self._module_of(p)] = module_counts.get(self._module_of(p), 0) + 1
|
|
260
|
+
module_counts.pop("", None)
|
|
261
|
+
dominant = max(module_counts, key=lambda m: module_counts[m], default=None)
|
|
262
|
+
dominant_share = (module_counts[dominant] / total) if (dominant and total) else 0.0
|
|
263
|
+
if module_counts:
|
|
264
|
+
used.append("module_masses")
|
|
265
|
+
|
|
266
|
+
# concept masses (share of source files whose path touches the concept)
|
|
267
|
+
concept_mass: dict[str, float] = {}
|
|
268
|
+
for concept, kws in _CONCEPTS.items():
|
|
269
|
+
hits = sum(
|
|
270
|
+
1 for p in src
|
|
271
|
+
if any(seg.lower() in kws for seg in p.split("/")[:-1])
|
|
272
|
+
)
|
|
273
|
+
concept_mass[concept] = (hits / total) if total else 0.0
|
|
274
|
+
if total:
|
|
275
|
+
used.append("concept_masses")
|
|
276
|
+
|
|
277
|
+
entry_kinds: dict[str, int] = {}
|
|
278
|
+
for ep in sm.entry_points or []:
|
|
279
|
+
k = getattr(ep, "kind", None) or "unknown"
|
|
280
|
+
entry_kinds[k] = entry_kinds.get(k, 0) + 1
|
|
281
|
+
if sm.entry_points:
|
|
282
|
+
used.append("entry_points")
|
|
283
|
+
else:
|
|
284
|
+
missing.append("entry_points")
|
|
285
|
+
|
|
286
|
+
endpoint_total = self._endpoint_count(root)
|
|
287
|
+
endpoint_share = (endpoint_total / total) if total else 0.0
|
|
288
|
+
if root is not None:
|
|
289
|
+
used.append("endpoints")
|
|
290
|
+
else:
|
|
291
|
+
missing.append("endpoints")
|
|
292
|
+
|
|
293
|
+
frameworks = {fw.name for s in (sm.stacks or []) for fw in s.frameworks}
|
|
294
|
+
if frameworks:
|
|
295
|
+
used.append("frameworks")
|
|
296
|
+
|
|
297
|
+
hubs: list[str] = []
|
|
298
|
+
cycles = 0
|
|
299
|
+
mgs = sm.module_graph_summary
|
|
300
|
+
if mgs is not None:
|
|
301
|
+
hubs = list(getattr(mgs, "hubs", []) or [])
|
|
302
|
+
cycles = int(getattr(mgs, "cycle_count", 0) or 0)
|
|
303
|
+
used.append("module_graph_summary")
|
|
304
|
+
|
|
305
|
+
# Module graph as a first-class Evidence Provider (ADR-0003 step 2).
|
|
306
|
+
# A ModuleGraph (nodes+edges) yields topological evidence the scorer can
|
|
307
|
+
# consume WITHOUT ever walking the graph. Absent/too-small graphs degrade
|
|
308
|
+
# to today's name-mass-only behaviour and are declared missing.
|
|
309
|
+
graph = module_graph if module_graph is not None else sm.module_graph
|
|
310
|
+
graph_result: GraphEvidenceResult = (
|
|
311
|
+
GraphEvidenceProvider().analyze(graph) if graph is not None
|
|
312
|
+
else GraphEvidenceResult(node_count=0, edge_count=0)
|
|
313
|
+
)
|
|
314
|
+
if graph_result.available:
|
|
315
|
+
used.append("module_graph")
|
|
316
|
+
if not cycles and graph_result.metric("cyclic_density") > 0:
|
|
317
|
+
cycles = 1 # topology confirms a cyclic core even without a summary
|
|
318
|
+
else:
|
|
319
|
+
missing.append("module_graph")
|
|
320
|
+
# Richer signals not wired yet — declare them so confidence reflects it.
|
|
321
|
+
missing.append("repo_ir_subsystem_masses")
|
|
322
|
+
missing.append("semantic_ir_spi_fanin")
|
|
323
|
+
|
|
324
|
+
return _Features(
|
|
325
|
+
total_files=total,
|
|
326
|
+
module_counts=module_counts,
|
|
327
|
+
dominant_module=dominant,
|
|
328
|
+
dominant_share=dominant_share,
|
|
329
|
+
module_count=len(module_counts),
|
|
330
|
+
concept_mass=concept_mass,
|
|
331
|
+
entry_kinds=entry_kinds,
|
|
332
|
+
endpoint_total=endpoint_total,
|
|
333
|
+
endpoint_share=endpoint_share,
|
|
334
|
+
packaging=sm.packaging,
|
|
335
|
+
frameworks=frameworks,
|
|
336
|
+
graph_hubs=hubs,
|
|
337
|
+
graph_cycles=cycles,
|
|
338
|
+
graph_available=graph_result.available,
|
|
339
|
+
graph_evidence=graph_result.evidence,
|
|
340
|
+
graph_metrics=graph_result.metrics,
|
|
341
|
+
signals_used=used,
|
|
342
|
+
signals_missing=missing,
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
# ── dimension scorers ─────────────────────────────────────────────────────
|
|
346
|
+
def _software_archetype(self, f: _Features) -> DimensionResult:
|
|
347
|
+
cm = f.concept_mass
|
|
348
|
+
cands: dict[str, list[Evidence]] = {a: [] for a in
|
|
349
|
+
("engine", "compiler", "framework", "library",
|
|
350
|
+
"application", "platform", "sdk")}
|
|
351
|
+
|
|
352
|
+
def add(label: str, signal: str, detail: str, weight: float, strength: float, mass: float):
|
|
353
|
+
cands[label].append(Evidence(signal, detail, weight, strength,
|
|
354
|
+
mass, weight * strength * mass))
|
|
355
|
+
|
|
356
|
+
# engine: storage/kernel mass is the core of the codebase
|
|
357
|
+
eng_mass = min(1.0, cm["storage"] + cm["kernel"])
|
|
358
|
+
add("engine", "storage_kernel_mass",
|
|
359
|
+
f"storage+kernel packages ≈ {eng_mass:.0%} of source", 3.0, 1.0, eng_mass)
|
|
360
|
+
if f.graph_cycles > 0:
|
|
361
|
+
add("engine", "cyclic_core",
|
|
362
|
+
f"{f.graph_cycles} import cycle(s) — tightly-woven engine core", 1.0, 1.0, 0.5)
|
|
363
|
+
# compiler: parser/ast/ir/codegen pipeline mass
|
|
364
|
+
add("compiler", "compiler_pipeline_mass",
|
|
365
|
+
f"parser/ast/ir/codegen packages ≈ {cm['compiler']:.0%}", 3.0, 1.0, cm["compiler"])
|
|
366
|
+
# framework: plugin/SPI extension surface + jar packaging + not app-entry
|
|
367
|
+
fw_strength = 1.0 if f.packaging == "jar" else 0.6
|
|
368
|
+
add("framework", "extension_surface_mass",
|
|
369
|
+
f"plugin/SPI/provider packages ≈ {cm['plugin']:.0%}", 2.5, fw_strength, cm["plugin"])
|
|
370
|
+
# library/SDK: public API surface, no server/cli bootstrap
|
|
371
|
+
has_server = any(k in f.entry_kinds for k in ("server", "jax_rs_controller"))
|
|
372
|
+
has_bootstrap = "bootstrap" in f.entry_kinds or "application" in f.entry_kinds
|
|
373
|
+
lib_strength = 0.4 if (has_server or has_bootstrap) else 1.0
|
|
374
|
+
add("library", "public_api_no_runtime",
|
|
375
|
+
f"api/contract packages ≈ {cm['api_pkg']:.0%}; "
|
|
376
|
+
f"{'no' if lib_strength == 1.0 else 'has'} server/bootstrap entry",
|
|
377
|
+
2.0, lib_strength, max(cm["api_pkg"], 0.15))
|
|
378
|
+
if cm["sdk_client"] > 0.1:
|
|
379
|
+
add("sdk", "client_packages",
|
|
380
|
+
f"client/sdk packages ≈ {cm['sdk_client']:.0%}", 2.0, 1.0, cm["sdk_client"])
|
|
381
|
+
# application: has bootstrap/server + business (service) mass
|
|
382
|
+
if has_bootstrap or has_server:
|
|
383
|
+
add("application", "runtime_entry_plus_services",
|
|
384
|
+
f"bootstrap/server entry + service mass ≈ {cm['service']:.0%}",
|
|
385
|
+
2.0, 1.0, max(cm["service"], 0.2))
|
|
386
|
+
# platform: many modules + multiple concept clusters + distribution
|
|
387
|
+
multi = 1.0 if f.module_count >= 8 else f.module_count / 8
|
|
388
|
+
add("platform", "many_modules_multi_concern",
|
|
389
|
+
f"{f.module_count} modules; distributed mass ≈ {cm['distributed']:.0%}",
|
|
390
|
+
1.5, multi, max(cm["distributed"], 0.1))
|
|
391
|
+
self._apply_graph_evidence("software_archetype", cands, f)
|
|
392
|
+
return self._resolve("software_archetype", cands, f)
|
|
393
|
+
|
|
394
|
+
def _architectural_style(self, f: _Features) -> DimensionResult:
|
|
395
|
+
cm = f.concept_mass
|
|
396
|
+
cands: dict[str, list[Evidence]] = {a: [] for a in
|
|
397
|
+
("layered", "hexagonal", "clean", "mvc",
|
|
398
|
+
"event-driven", "plugin-based", "microkernel")}
|
|
399
|
+
|
|
400
|
+
def add(label, signal, detail, weight, strength, mass):
|
|
401
|
+
cands[label].append(Evidence(signal, detail, weight, strength,
|
|
402
|
+
mass, weight * strength * mass))
|
|
403
|
+
|
|
404
|
+
# layered: needs BOTH web/controller AND service AND repository mass — and each
|
|
405
|
+
# must be non-trivial. Legacy asserted this from mere name presence; we weight
|
|
406
|
+
# by mass so a 0.3% `api/` dir does not manufacture a "controller layer".
|
|
407
|
+
layered_present = sum(1 for c in ("web", "service", "repository") if cm[c] >= 0.05)
|
|
408
|
+
layered_mass = min(1.0, cm["web"] + cm["service"] + cm["repository"])
|
|
409
|
+
add("layered", "layered_triplet_mass",
|
|
410
|
+
f"controller/service/repository mass ≈ {layered_mass:.0%} "
|
|
411
|
+
f"({layered_present}/3 layers above 5%)",
|
|
412
|
+
2.0, layered_present / 3, layered_mass)
|
|
413
|
+
# mvc requires a view/web surface with real mass
|
|
414
|
+
add("mvc", "web_view_mass", f"web/controller mass ≈ {cm['web']:.0%}",
|
|
415
|
+
2.0, 1.0 if cm["web"] >= 0.05 else 0.0, cm["web"])
|
|
416
|
+
# hexagonal / clean
|
|
417
|
+
add("hexagonal", "ports_adapters_mass", f"ports/adapters mass ≈ {cm['hexagonal']:.0%}",
|
|
418
|
+
2.5, 1.0, cm["hexagonal"])
|
|
419
|
+
add("clean", "domain_usecase_infra_mass", f"domain/usecase/infra mass ≈ {cm['clean']:.0%}",
|
|
420
|
+
2.5, 1.0, cm["clean"])
|
|
421
|
+
# event-driven
|
|
422
|
+
add("event-driven", "event_mass", f"event/listener/bus mass ≈ {cm['event']:.0%}",
|
|
423
|
+
2.0, 1.0, cm["event"])
|
|
424
|
+
# plugin-based / microkernel: plugin mass + small dominant core
|
|
425
|
+
add("plugin-based", "plugin_mass", f"plugin/extension mass ≈ {cm['plugin']:.0%}",
|
|
426
|
+
2.0, 1.0, cm["plugin"])
|
|
427
|
+
micro_strength = 1.0 if (cm["plugin"] > 0.05 and f.dominant_share < 0.4) else 0.0
|
|
428
|
+
add("microkernel", "small_core_many_plugins",
|
|
429
|
+
f"plugin mass ≈ {cm['plugin']:.0%}, dominant module {f.dominant_share:.0%}",
|
|
430
|
+
1.5, micro_strength, max(cm["plugin"], 0.1))
|
|
431
|
+
self._apply_graph_evidence("architectural_style", cands, f)
|
|
432
|
+
return self._resolve("architectural_style", cands, f)
|
|
433
|
+
|
|
434
|
+
def _deployment_shape(self, f: _Features) -> DimensionResult:
|
|
435
|
+
cm = f.concept_mass
|
|
436
|
+
cands: dict[str, list[Evidence]] = {a: [] for a in
|
|
437
|
+
("monolith", "multi-module", "distributed", "embedded")}
|
|
438
|
+
|
|
439
|
+
def add(label, signal, detail, weight, strength, mass):
|
|
440
|
+
cands[label].append(Evidence(signal, detail, weight, strength,
|
|
441
|
+
mass, weight * strength * mass))
|
|
442
|
+
|
|
443
|
+
multi = 1.0 if f.module_count >= 3 else f.module_count / 3
|
|
444
|
+
add("multi-module", "module_count", f"{f.module_count} source modules", 2.0, multi, 1.0)
|
|
445
|
+
# monolith: modules but a single deployable (one war, or a single dominant module)
|
|
446
|
+
mono_strength = 1.0 if (f.packaging == "war" or f.dominant_share >= 0.5) else 0.3
|
|
447
|
+
add("monolith", "single_deployable",
|
|
448
|
+
f"packaging={f.packaging}, dominant module {f.dominant_share:.0%}",
|
|
449
|
+
1.5, mono_strength, 1.0)
|
|
450
|
+
add("distributed", "cluster_mass", f"cluster/raft/discovery mass ≈ {cm['distributed']:.0%}",
|
|
451
|
+
2.5, 1.0, cm["distributed"])
|
|
452
|
+
# embedded: library packaging + embeddable API, no server bootstrap
|
|
453
|
+
emb_strength = 1.0 if (f.packaging == "jar" and "server" not in f.entry_kinds) else 0.3
|
|
454
|
+
add("embedded", "library_packaging_no_server",
|
|
455
|
+
f"packaging={f.packaging}, {'no' if 'server' not in f.entry_kinds else 'has'} server entry",
|
|
456
|
+
1.5, emb_strength, 0.5)
|
|
457
|
+
return self._resolve("deployment_shape", cands, f)
|
|
458
|
+
|
|
459
|
+
def _primary_interface(self, f: _Features) -> DimensionResult:
|
|
460
|
+
cm = f.concept_mass
|
|
461
|
+
cands: dict[str, list[Evidence]] = {a: [] for a in
|
|
462
|
+
("api-first", "cli", "worker", "daemon",
|
|
463
|
+
"library", "embedded")}
|
|
464
|
+
|
|
465
|
+
def add(label, signal, detail, weight, strength, mass):
|
|
466
|
+
cands[label].append(Evidence(signal, detail, weight, strength,
|
|
467
|
+
mass, weight * strength * mass))
|
|
468
|
+
|
|
469
|
+
# api-first: HTTP surface must be significant relative to size. Ramp so a
|
|
470
|
+
# rounding-error surface cannot win: 0 below 0.5 endpoints/100 files, linear
|
|
471
|
+
# to full strength at >=2/100 files. (neo4j: 18/5600 = 0.32/100 -> 0.)
|
|
472
|
+
density = f.endpoint_share * 100
|
|
473
|
+
api_strength = max(0.0, min(1.0, (density - 0.5) / 1.5)) if f.endpoint_total else 0.0
|
|
474
|
+
add("api-first", "endpoint_density",
|
|
475
|
+
f"{f.endpoint_total} endpoints over {f.total_files} files "
|
|
476
|
+
f"({density:.2f}/100 files)",
|
|
477
|
+
3.0, api_strength, 1.0)
|
|
478
|
+
cli_entries = f.entry_kinds.get("cli", 0)
|
|
479
|
+
add("cli", "cli_entry_points", f"{cli_entries} CLI entry point(s); cli mass ≈ {cm['cli']:.0%}",
|
|
480
|
+
2.5, 1.0 if cli_entries else (1.0 if cm["cli"] > 0.05 else 0.0), max(cm["cli"], 0.2))
|
|
481
|
+
add("daemon", "server_bootstrap",
|
|
482
|
+
f"server/bootstrap entries: {f.entry_kinds.get('server',0)+f.entry_kinds.get('bootstrap',0)}",
|
|
483
|
+
2.0, 1.0 if ("server" in f.entry_kinds or "bootstrap" in f.entry_kinds) else 0.0, 0.5)
|
|
484
|
+
add("worker", "event_no_http",
|
|
485
|
+
f"event mass ≈ {cm['event']:.0%}, endpoints={f.endpoint_total}",
|
|
486
|
+
1.5, 1.0 if (cm["event"] > 0.05 and f.endpoint_total == 0) else 0.0, max(cm["event"], 0.1))
|
|
487
|
+
lib_strength = 1.0 if (f.packaging == "jar" and not f.entry_kinds) else 0.3
|
|
488
|
+
add("library", "consumed_as_dependency",
|
|
489
|
+
f"packaging={f.packaging}, entry kinds={list(f.entry_kinds) or 'none'}",
|
|
490
|
+
1.5, lib_strength, 0.4)
|
|
491
|
+
add("embedded", "embeddable_api", f"api mass ≈ {cm['api_pkg']:.0%}, packaging={f.packaging}",
|
|
492
|
+
1.2, 1.0 if (f.packaging == "jar" and cm["api_pkg"] > 0.03) else 0.0, 0.4)
|
|
493
|
+
return self._resolve("primary_interface", cands, f)
|
|
494
|
+
|
|
495
|
+
# ── graph evidence bridge ──────────────────────────────────────────────────
|
|
496
|
+
def _apply_graph_evidence(
|
|
497
|
+
self, dimension: str, cands: dict[str, list[Evidence]], f: _Features,
|
|
498
|
+
) -> None:
|
|
499
|
+
"""Translate topological GraphEvidence into archetype Evidence for this
|
|
500
|
+
dimension, via the declarative `_GRAPH_MAP`. The scorer consumes evidence
|
|
501
|
+
objects here; it never inspects nodes or edges. A metric with polarity -1
|
|
502
|
+
contributes NEGATIVELY (e.g. a dense cyclic core refutes 'layered')."""
|
|
503
|
+
if not f.graph_available:
|
|
504
|
+
return
|
|
505
|
+
ev_by_kind = {e.kind: e for e in f.graph_evidence}
|
|
506
|
+
for kind, mappings in _GRAPH_MAP.items():
|
|
507
|
+
ge = ev_by_kind.get(kind)
|
|
508
|
+
if ge is None:
|
|
509
|
+
continue
|
|
510
|
+
for dim, label, weight, polarity, lo, hi in mappings:
|
|
511
|
+
if dim != dimension or label not in cands:
|
|
512
|
+
continue
|
|
513
|
+
span = (hi - lo) or 1.0
|
|
514
|
+
strength = max(0.0, min(1.0, (ge.value - lo) / span))
|
|
515
|
+
if strength <= 0.0:
|
|
516
|
+
continue
|
|
517
|
+
contribution = weight * strength * ge.mass * polarity
|
|
518
|
+
sign = "" if polarity > 0 else "_refutes"
|
|
519
|
+
cands[label].append(Evidence(
|
|
520
|
+
signal=f"graph_{kind}{sign}",
|
|
521
|
+
detail=ge.detail,
|
|
522
|
+
weight=weight, strength=strength, mass=ge.mass,
|
|
523
|
+
contribution=contribution,
|
|
524
|
+
))
|
|
525
|
+
|
|
526
|
+
# ── conflict resolution + confidence ──────────────────────────────────────
|
|
527
|
+
def _resolve(self, dimension: str, raw: dict[str, list[Evidence]], f: _Features) -> DimensionResult:
|
|
528
|
+
candidates = [
|
|
529
|
+
ArchetypeCandidate(label=lbl, score=sum(e.contribution for e in evs), evidence=evs)
|
|
530
|
+
for lbl, evs in raw.items()
|
|
531
|
+
]
|
|
532
|
+
candidates.sort(key=lambda c: c.score, reverse=True)
|
|
533
|
+
top = candidates[0] if candidates else None
|
|
534
|
+
runner = candidates[1] if len(candidates) > 1 else None
|
|
535
|
+
# Floor: do not declare a winner on negligible evidence (e.g. a 0.3% plugin
|
|
536
|
+
# dir "winning" architectural_style). Below it, the dimension is undetermined.
|
|
537
|
+
primary = top.label if top and top.score >= _MIN_PRIMARY_SCORE else None
|
|
538
|
+
|
|
539
|
+
# Confidence from margin between top-2 + whether mass/graph evidence backs it
|
|
540
|
+
# (vs name-only), plus penalties for missing richer signals.
|
|
541
|
+
confidence = "low"
|
|
542
|
+
if top and top.score > 0:
|
|
543
|
+
margin = top.score - (runner.score if runner else 0.0)
|
|
544
|
+
rel_margin = margin / top.score if top.score else 0.0
|
|
545
|
+
mass_backed = any(e.mass < 1.0 and e.contribution > 0 for e in top.evidence)
|
|
546
|
+
# Graph-backed = the WINNING conclusion is supported by real topology,
|
|
547
|
+
# not merely that a graph exists. Ties "high" confidence to structural
|
|
548
|
+
# evidence for THIS label (ADR-0003 acceptance criterion).
|
|
549
|
+
graph_backed = any(
|
|
550
|
+
e.signal.startswith("graph_") and e.contribution > 0 for e in top.evidence
|
|
551
|
+
)
|
|
552
|
+
# Elevated confidence also requires a non-marginal absolute score: a
|
|
553
|
+
# winner barely above the evidence floor is "undetermined-ish" and must
|
|
554
|
+
# not read as high/medium however lonely it is.
|
|
555
|
+
confident_enough = top.score >= _MIN_CONFIDENT_SCORE
|
|
556
|
+
if confident_enough and rel_margin >= 0.4 and mass_backed and graph_backed:
|
|
557
|
+
confidence = "high"
|
|
558
|
+
elif confident_enough and rel_margin >= 0.25 and mass_backed:
|
|
559
|
+
confidence = "medium"
|
|
560
|
+
else:
|
|
561
|
+
confidence = "low"
|
|
562
|
+
|
|
563
|
+
if primary is None:
|
|
564
|
+
explanation = (
|
|
565
|
+
f"No dominant {dimension.replace('_', ' ')}: best candidate "
|
|
566
|
+
f"'{top.label}' ({top.score:.3f}) is below the evidence floor "
|
|
567
|
+
f"({_MIN_PRIMARY_SCORE}) — undetermined."
|
|
568
|
+
) if top else f"No evidence for {dimension.replace('_', ' ')}."
|
|
569
|
+
confidence = "low" if not top or top.score == 0 else confidence
|
|
570
|
+
else:
|
|
571
|
+
explanation = self._explain(dimension, top, runner)
|
|
572
|
+
return DimensionResult(dimension, primary, confidence, candidates, explanation)
|
|
573
|
+
|
|
574
|
+
def _explain(self, dimension: str, top: Optional[ArchetypeCandidate],
|
|
575
|
+
runner: Optional[ArchetypeCandidate]) -> str:
|
|
576
|
+
if top is None or top.score <= 0:
|
|
577
|
+
return f"No archetype evidence for {dimension}."
|
|
578
|
+
parts = [f"{e.signal}(+{e.contribution:.3f})"
|
|
579
|
+
for e in sorted(top.evidence, key=lambda e: e.contribution, reverse=True)
|
|
580
|
+
if e.contribution > 0][:3]
|
|
581
|
+
base = f"'{top.label}' wins ({top.score:.3f}) on {', '.join(parts) or 'weak signals'}"
|
|
582
|
+
if runner and runner.score > 0:
|
|
583
|
+
base += f"; over '{runner.label}' ({runner.score:.3f})"
|
|
584
|
+
return base + "."
|
|
585
|
+
|
|
586
|
+
# ── helpers ───────────────────────────────────────────────────────────────
|
|
587
|
+
@staticmethod
|
|
588
|
+
def _module_of(path: str) -> str:
|
|
589
|
+
norm = path.replace("\\", "/")
|
|
590
|
+
idx = norm.find("/src/")
|
|
591
|
+
if idx > 0:
|
|
592
|
+
return norm[:idx]
|
|
593
|
+
head, _, tail = norm.partition("/")
|
|
594
|
+
return head if tail else ""
|
|
595
|
+
|
|
596
|
+
@staticmethod
|
|
597
|
+
def _endpoint_count(root: Optional[Path]) -> int:
|
|
598
|
+
if root is None:
|
|
599
|
+
return 0
|
|
600
|
+
try:
|
|
601
|
+
from sourcecode.repository_ir import extract_java_endpoints
|
|
602
|
+
data = extract_java_endpoints(root)
|
|
603
|
+
return int(data.get("total", len(data.get("endpoints", []))))
|
|
604
|
+
except Exception:
|
|
605
|
+
return 0
|