sourcecode 2.2.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/classifier.py +94 -1
- sourcecode/cli.py +73 -0
- sourcecode/graph_evidence.py +352 -0
- {sourcecode-2.2.0.dist-info → sourcecode-2.3.0.dist-info}/METADATA +3 -3
- {sourcecode-2.2.0.dist-info → sourcecode-2.3.0.dist-info}/RECORD +11 -9
- {sourcecode-2.2.0.dist-info → sourcecode-2.3.0.dist-info}/WHEEL +0 -0
- {sourcecode-2.2.0.dist-info → sourcecode-2.3.0.dist-info}/entry_points.txt +0 -0
- {sourcecode-2.2.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
|
|
@@ -31,6 +31,18 @@ _CORE_DETECTION_MODULES = {"scanner", "detectors", "classifier", "workspace"}
|
|
|
31
31
|
# headline to a qualified, consistent phrasing instead of overclaiming.
|
|
32
32
|
_MIN_REST_ENDPOINTS_FOR_LABEL = 5
|
|
33
33
|
|
|
34
|
+
# BUG (neo4j field test): endpoint COUNT alone cannot separate "an API server"
|
|
35
|
+
# from "a large engine/platform that also exposes a few HTTP endpoints". A graph
|
|
36
|
+
# database with ~14 JAX-RS endpoints across 5,600+ source files is not a "REST
|
|
37
|
+
# API" — yet it clears the count threshold above and gets mis-headlined, the exact
|
|
38
|
+
# thing a reader sees in the first line. Add a proportionality guard: on a large
|
|
39
|
+
# JVM codebase, only keep the "REST API" headline when the HTTP surface is
|
|
40
|
+
# architecturally significant relative to code size (at least one endpoint per
|
|
41
|
+
# _API_DOMINANCE_FILES_PER_ENDPOINT source files). Below that density the web
|
|
42
|
+
# layer is a minor component, not the architecture.
|
|
43
|
+
_LARGE_JVM_SOURCE_FILES = 400
|
|
44
|
+
_API_DOMINANCE_FILES_PER_ENDPOINT = 100
|
|
45
|
+
|
|
34
46
|
_OPTIONAL_LABEL_MAP: dict[str, str] = {
|
|
35
47
|
"DependencyAnalyzer": "dependencias",
|
|
36
48
|
"GraphAnalyzer": "grafo de módulos",
|
|
@@ -207,6 +219,21 @@ class ArchitectureSummarizer:
|
|
|
207
219
|
f"(HTTP framework present; only {total} endpoint{plural} "
|
|
208
220
|
f"detected — see `endpoints`)."
|
|
209
221
|
)
|
|
222
|
+
# Proportionality guard: a large JVM codebase whose HTTP surface is
|
|
223
|
+
# a rounding error is an engine/platform exposing an API, not an
|
|
224
|
+
# "API" project. Endpoint count already cleared the threshold above,
|
|
225
|
+
# so lead with what the system IS instead of overclaiming "REST API".
|
|
226
|
+
n_src = self._jvm_source_file_count(sm)
|
|
227
|
+
if (
|
|
228
|
+
n_src >= _LARGE_JVM_SOURCE_FILES
|
|
229
|
+
and total * _API_DOMINANCE_FILES_PER_ENDPOINT < n_src
|
|
230
|
+
):
|
|
231
|
+
plural = "s" if total != 1 else ""
|
|
232
|
+
return (
|
|
233
|
+
f"{stack_label} multi-module codebase{fw_str} — large "
|
|
234
|
+
f"system ({n_src} source files) with a minor HTTP surface "
|
|
235
|
+
f"({total} endpoint{plural}); not API-first. See `endpoints`."
|
|
236
|
+
)
|
|
210
237
|
return f"{stack_label} {runtime.lower()}{fw_str}."
|
|
211
238
|
return f"{stack_label} project{fw_str}."
|
|
212
239
|
|
|
@@ -228,6 +255,20 @@ class ArchitectureSummarizer:
|
|
|
228
255
|
self._endpoint_support_cache = (total, high)
|
|
229
256
|
return self._endpoint_support_cache
|
|
230
257
|
|
|
258
|
+
def _jvm_source_file_count(self, sm: SourceMap) -> int:
|
|
259
|
+
"""Count JVM source files in the scanned tree (excludes tooling paths).
|
|
260
|
+
Used by the proportionality guard to tell a large engine/platform apart
|
|
261
|
+
from an API-first project. A truncated tree only undercounts, which makes
|
|
262
|
+
the guard more conservative (less likely to degrade a real API)."""
|
|
263
|
+
exts = (".java", ".kt", ".kts", ".scala")
|
|
264
|
+
try:
|
|
265
|
+
return sum(
|
|
266
|
+
1 for p in flatten_file_tree(sm.file_tree)
|
|
267
|
+
if p.endswith(exts) and not self._is_tooling_path(p)
|
|
268
|
+
)
|
|
269
|
+
except Exception:
|
|
270
|
+
return 0
|
|
271
|
+
|
|
231
272
|
def _qualify_web_pattern(self, arch: Any, arch_line: str, sm: SourceMap) -> str:
|
|
232
273
|
"""BUG #1 (Jenkins field test): the "mvc" pattern is a directory-name
|
|
233
274
|
heuristic (dirs matching controller/model/view keywords). On a Java/Kotlin
|
sourcecode/classifier.py
CHANGED
|
@@ -34,6 +34,30 @@ _API_FRAMEWORKS = {
|
|
|
34
34
|
_WEB_FRAMEWORKS = {"Next.js", "React", "Vue", "Svelte", "Vite", "Flutter", "Phoenix", "Angular"}
|
|
35
35
|
_CLI_FRAMEWORKS = {"Typer", "Cobra", "Clap"}
|
|
36
36
|
_API_STACKS = {"python", "go", "java", "php", "ruby", "dotnet", "kotlin", "scala"}
|
|
37
|
+
|
|
38
|
+
# Center-of-gravity gate for the "api" decision (neo4j field test).
|
|
39
|
+
# The historical rule was pure PRESENCE: any API framework on the classpath →
|
|
40
|
+
# project_type="api". That mislabels a large engine/platform that merely exposes a
|
|
41
|
+
# small HTTP surface. Two evidence paths defeat the existing adapter-localization
|
|
42
|
+
# guard because they are repo-wide (no locatable minority module):
|
|
43
|
+
# * a jakarta.ws.rs / javax.ws.rs-api coordinate pinned in a BOM/parent pom's
|
|
44
|
+
# <dependencyManagement> (a version pin, not usage), and
|
|
45
|
+
# * a source-annotation "Jakarta EE" marker synthesized from @Path resources
|
|
46
|
+
# (detected_via carries no file path).
|
|
47
|
+
# So on a repo large enough to HAVE a distinct center of gravity, require the API
|
|
48
|
+
# surface to occupy a real share of the code (or the dominant module) before we
|
|
49
|
+
# headline the whole system as an API. Small/single-module repos keep presence-based
|
|
50
|
+
# behavior (a real API framework there defines the project — e.g. the Eureka field
|
|
51
|
+
# test). JVM-only: this is where the false positive lives; other stacks are untouched.
|
|
52
|
+
_JVM_STACKS = {"java", "kotlin", "scala", "groovy"}
|
|
53
|
+
_JVM_CODE_EXTS = (".java", ".kt", ".kts", ".scala", ".groovy")
|
|
54
|
+
_API_DOMINANCE_MIN_FILES = 300
|
|
55
|
+
_API_PRIMARY_SHARE = 0.15
|
|
56
|
+
_API_SURFACE_ENTRY_KINDS = frozenset({
|
|
57
|
+
"jax_rs_controller", "jax_rs_provider",
|
|
58
|
+
"rest_controller", "mvc_controller", "controller", "web", "server",
|
|
59
|
+
})
|
|
60
|
+
|
|
37
61
|
ConfidenceLevel = Literal["high", "medium", "low"]
|
|
38
62
|
|
|
39
63
|
|
|
@@ -143,7 +167,18 @@ class TypeClassifier:
|
|
|
143
167
|
return "web_mvc"
|
|
144
168
|
|
|
145
169
|
if framework_names & _API_FRAMEWORKS:
|
|
146
|
-
|
|
170
|
+
if not (stack_names & _JVM_STACKS) or self._api_is_center_of_gravity(
|
|
171
|
+
file_tree, stacks, entry_points
|
|
172
|
+
):
|
|
173
|
+
return "api"
|
|
174
|
+
# A JVM API framework is present but is NOT the repo's center of gravity.
|
|
175
|
+
# It survived adapter-localization only because its evidence is repo-wide
|
|
176
|
+
# (a BOM/parent-pom version pin, or a source-annotation marker with no
|
|
177
|
+
# locatable minority module). Do not headline the whole system as an API —
|
|
178
|
+
# the framework stays in stacks[].frameworks as a secondary capability and
|
|
179
|
+
# the primary type falls back to the structural center of gravity.
|
|
180
|
+
if self._is_multi_module(file_tree):
|
|
181
|
+
return "library"
|
|
147
182
|
|
|
148
183
|
# All app-defining frameworks were localized to optional adapter submodules
|
|
149
184
|
# (multi-module library with per-framework integrations) — report library,
|
|
@@ -263,6 +298,64 @@ class TypeClassifier:
|
|
|
263
298
|
localized.add(fw_name)
|
|
264
299
|
return localized
|
|
265
300
|
|
|
301
|
+
def _api_is_center_of_gravity(
|
|
302
|
+
self,
|
|
303
|
+
file_tree: dict[str, Any],
|
|
304
|
+
stacks: Sequence[StackDetection],
|
|
305
|
+
entry_points: Sequence[EntryPoint],
|
|
306
|
+
) -> bool:
|
|
307
|
+
"""True when an API/web framework reflects the repo's center of gravity, not
|
|
308
|
+
merely its presence. Small or single-module JVM repos: a present API framework
|
|
309
|
+
defines the project (keeps the historical behavior). Large multi-module repos:
|
|
310
|
+
the HTTP surface must live in the dominant module or occupy at least
|
|
311
|
+
``_API_PRIMARY_SHARE`` of the JVM source — otherwise it is a minor capability
|
|
312
|
+
of an engine/platform/library, not the primary type."""
|
|
313
|
+
counts: dict[str, int] = {}
|
|
314
|
+
for p in flatten_file_tree(file_tree):
|
|
315
|
+
if p.endswith(_JVM_CODE_EXTS):
|
|
316
|
+
mod = self._module_of(p)
|
|
317
|
+
counts[mod] = counts.get(mod, 0) + 1
|
|
318
|
+
counts.pop("", None)
|
|
319
|
+
total = sum(counts.values())
|
|
320
|
+
# Too small, or a single module, to reason about a center of gravity.
|
|
321
|
+
if total < _API_DOMINANCE_MIN_FILES or len(counts) < 2:
|
|
322
|
+
return True
|
|
323
|
+
dominant = max(counts, key=lambda m: counts[m])
|
|
324
|
+
api_modules = self._api_surface_modules(stacks, entry_points)
|
|
325
|
+
if not api_modules:
|
|
326
|
+
# API framework known only repo-wide (BOM version pin / annotation marker)
|
|
327
|
+
# with no source-located HTTP surface in a large multi-module system.
|
|
328
|
+
return False
|
|
329
|
+
if dominant in api_modules:
|
|
330
|
+
return True
|
|
331
|
+
api_files = sum(counts.get(m, 0) for m in api_modules)
|
|
332
|
+
return (api_files / total) >= _API_PRIMARY_SHARE
|
|
333
|
+
|
|
334
|
+
def _api_surface_modules(
|
|
335
|
+
self,
|
|
336
|
+
stacks: Sequence[StackDetection],
|
|
337
|
+
entry_points: Sequence[EntryPoint],
|
|
338
|
+
) -> set[str]:
|
|
339
|
+
"""Modules that actually host an HTTP/REST surface: derived from API entry
|
|
340
|
+
points (controllers / JAX-RS resources) and from API-framework file evidence.
|
|
341
|
+
Repo-wide manifest evidence (no path) contributes no module, by design."""
|
|
342
|
+
modules: set[str] = set()
|
|
343
|
+
for ep in entry_points:
|
|
344
|
+
if getattr(ep, "kind", None) in _API_SURFACE_ENTRY_KINDS and ep.path:
|
|
345
|
+
modules.add(self._module_of(ep.path))
|
|
346
|
+
for stack in stacks:
|
|
347
|
+
for fw in stack.frameworks:
|
|
348
|
+
if fw.name not in _API_FRAMEWORKS:
|
|
349
|
+
continue
|
|
350
|
+
for ev in fw.detected_via:
|
|
351
|
+
if ev.startswith("manifest:"):
|
|
352
|
+
continue
|
|
353
|
+
m = self._EVIDENCE_PATH_RE.search(ev)
|
|
354
|
+
if m:
|
|
355
|
+
modules.add(self._module_of(m.group(1).strip()))
|
|
356
|
+
modules.discard("")
|
|
357
|
+
return modules
|
|
358
|
+
|
|
266
359
|
def _is_fullstack(self, stacks: Sequence[StackDetection]) -> bool:
|
|
267
360
|
has_web = False
|
|
268
361
|
has_api = False
|
sourcecode/cli.py
CHANGED
|
@@ -246,6 +246,8 @@ _SUBCOMMANDS: frozenset[str] = frozenset(
|
|
|
246
246
|
"rename-class",
|
|
247
247
|
# Large file semantic chunking (BLOCKER-B)
|
|
248
248
|
"chunk-file",
|
|
249
|
+
# Experimental evidence-based archetype (parallel to legacy)
|
|
250
|
+
"archetype",
|
|
249
251
|
}
|
|
250
252
|
)
|
|
251
253
|
|
|
@@ -6422,6 +6424,77 @@ def config_cmd() -> None:
|
|
|
6422
6424
|
|
|
6423
6425
|
# ── cold-start (RIS bootstrap for external MCP and agents) ───────────────────
|
|
6424
6426
|
|
|
6427
|
+
@app.command("archetype")
|
|
6428
|
+
def archetype_cmd(
|
|
6429
|
+
path: Path = typer.Argument(Path("."), help="Repository path (default: current directory)"),
|
|
6430
|
+
output_path: Optional[Path] = typer.Option(
|
|
6431
|
+
None, "--output", "-o", help="Write output to a file instead of stdout."
|
|
6432
|
+
),
|
|
6433
|
+
) -> None:
|
|
6434
|
+
"""[EXPERIMENTAL] Evidence-based architectural archetype (parallel to legacy).
|
|
6435
|
+
|
|
6436
|
+
Emits a 4-dimension archetype analysis — software_archetype, architectural_style,
|
|
6437
|
+
deployment_shape, primary_interface — each with scored candidates, the evidence
|
|
6438
|
+
(weighted by code mass), a confidence level, and a human explanation.
|
|
6439
|
+
|
|
6440
|
+
This runs ALONGSIDE the legacy architecture pattern and does NOT change any
|
|
6441
|
+
report wording. It exists to validate the new model against the old one across
|
|
6442
|
+
many repositories before any cutover.
|
|
6443
|
+
"""
|
|
6444
|
+
import json as _json
|
|
6445
|
+
from sourcecode.adaptive_scanner import AdaptiveScanner
|
|
6446
|
+
from sourcecode.repo_classifier import RepoClassifier
|
|
6447
|
+
from sourcecode.detectors import ProjectDetector, build_default_detectors
|
|
6448
|
+
from sourcecode.schema import SourceMap
|
|
6449
|
+
from sourcecode.tree_utils import flatten_file_tree
|
|
6450
|
+
from sourcecode.archetype import ArchetypeClassifier
|
|
6451
|
+
|
|
6452
|
+
target = Path(path).resolve()
|
|
6453
|
+
topology = RepoClassifier().classify(target)
|
|
6454
|
+
scanner = AdaptiveScanner(target, topology=topology, base_depth=12)
|
|
6455
|
+
file_tree = scanner.scan_tree()
|
|
6456
|
+
manifests = scanner.find_manifests()
|
|
6457
|
+
|
|
6458
|
+
detector = ProjectDetector(build_default_detectors())
|
|
6459
|
+
stacks, entry_points, _ = detector.detect(target, file_tree, manifests)
|
|
6460
|
+
stacks, project_type = detector.classify_results(file_tree, stacks, entry_points)
|
|
6461
|
+
|
|
6462
|
+
sm = SourceMap(
|
|
6463
|
+
file_tree=file_tree, stacks=stacks,
|
|
6464
|
+
project_type=project_type, entry_points=entry_points,
|
|
6465
|
+
)
|
|
6466
|
+
sm.file_paths = [p.replace("\\", "/") for p in flatten_file_tree(file_tree)]
|
|
6467
|
+
_java = next((s for s in stacks if s.stack == "java"), None)
|
|
6468
|
+
if _java is not None:
|
|
6469
|
+
sm.packaging = getattr(_java, "packaging", None)
|
|
6470
|
+
|
|
6471
|
+
# Module graph as a first-class Evidence Provider (ADR-0003 step 2). Built
|
|
6472
|
+
# here so archetype can consume topological evidence. Bounded + best-effort:
|
|
6473
|
+
# on very large trees or any failure it degrades to None (name-mass-only),
|
|
6474
|
+
# which is exactly the pre-provider behaviour — no regression.
|
|
6475
|
+
module_graph = None
|
|
6476
|
+
try:
|
|
6477
|
+
from sourcecode.graph_analyzer import GraphAnalyzer
|
|
6478
|
+
module_graph = GraphAnalyzer(max_nodes=1200, max_edges=4000).analyze(
|
|
6479
|
+
target, file_tree, detail="full", entry_points=entry_points,
|
|
6480
|
+
)
|
|
6481
|
+
if module_graph is not None:
|
|
6482
|
+
sm.module_graph = module_graph
|
|
6483
|
+
sm.module_graph_summary = module_graph.summary
|
|
6484
|
+
except Exception:
|
|
6485
|
+
module_graph = None
|
|
6486
|
+
|
|
6487
|
+
analysis = ArchetypeClassifier().analyze(sm, root=target, module_graph=module_graph)
|
|
6488
|
+
result = analysis.to_dict()
|
|
6489
|
+
result["legacy_project_type"] = project_type # side-by-side with legacy for validation
|
|
6490
|
+
out = _json.dumps(result, indent=2, ensure_ascii=False)
|
|
6491
|
+
if output_path:
|
|
6492
|
+
_safe_write_file(output_path, out)
|
|
6493
|
+
typer.echo(f"Archetype analysis written to {output_path}")
|
|
6494
|
+
else:
|
|
6495
|
+
typer.echo(out)
|
|
6496
|
+
|
|
6497
|
+
|
|
6425
6498
|
@app.command("cold-start")
|
|
6426
6499
|
def cold_start_cmd(
|
|
6427
6500
|
path: Path = typer.Argument(Path("."), help="Repository path (default: current directory)"),
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
"""Graph Evidence Provider — turns the structural module graph into reusable
|
|
2
|
+
topological evidence (ADR-0003, migration step 2).
|
|
3
|
+
|
|
4
|
+
Responsibility boundary (deliberately narrow):
|
|
5
|
+
|
|
6
|
+
ModuleGraph -> [ this module ] -> topological metrics -> GraphEvidence
|
|
7
|
+
|
|
8
|
+
This component knows GRAPHS, not ARCHETYPES. It does not classify, does not
|
|
9
|
+
emit prose verdicts, and never imports the archetype scorer. It computes a
|
|
10
|
+
small, robust set of topological metrics and packages each as a language- and
|
|
11
|
+
domain-agnostic ``GraphEvidence`` object. The archetype scorer consumes those
|
|
12
|
+
evidence objects; it never traverses the graph itself.
|
|
13
|
+
|
|
14
|
+
The metrics are chosen so that architectural style can emerge from STRUCTURE
|
|
15
|
+
rather than from directory names:
|
|
16
|
+
|
|
17
|
+
* scc_mass — fraction of nodes that sit inside a NON-TRIVIAL
|
|
18
|
+
strongly-connected component (an import cycle).
|
|
19
|
+
Range [0,1]. 0 for a clean DAG (layered/
|
|
20
|
+
hexagonal); high for a woven engine core.
|
|
21
|
+
Cost O(V+E) (iterative Tarjan).
|
|
22
|
+
* cyclic_density — fraction of edges that live INSIDE a non-trivial
|
|
23
|
+
SCC (true back-edges). Range [0,1]. Low for
|
|
24
|
+
layered; high for engine. Cost O(V+E).
|
|
25
|
+
* hub_concentration — share of all edges pointing at the single
|
|
26
|
+
highest fan-in node. Range [0,1]. High for
|
|
27
|
+
engine/microkernel cores. Cost O(V+E).
|
|
28
|
+
* module_centralization — Freeman centralization of the in-degree
|
|
29
|
+
distribution. Range [0,1]. ~1 for a star
|
|
30
|
+
(microkernel), ~0 for a uniform mesh. Cost O(V).
|
|
31
|
+
* downward_edge_ratio — fraction of edges flowing from a LESS stable to
|
|
32
|
+
a MORE stable node (Martin instability
|
|
33
|
+
I = out/(in+out)). Range [0,1]. High for
|
|
34
|
+
layered/hexagonal directed flow. Cost O(V+E).
|
|
35
|
+
* dependency_inversion_ratio— fraction of edges whose TARGET sits in the most
|
|
36
|
+
stable quartile (a stable, depended-upon core).
|
|
37
|
+
Range [0,1]. High for hexagonal/clean. O(V+E).
|
|
38
|
+
* fan_in_gini — Gini inequality of the in-degree distribution.
|
|
39
|
+
Range [0,1]. High when a few modules absorb most
|
|
40
|
+
dependencies (engine). Cost O(V log V).
|
|
41
|
+
|
|
42
|
+
All metrics are name-free: they are functions of the directed topology only.
|
|
43
|
+
"""
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
from dataclasses import dataclass, field
|
|
47
|
+
from typing import Iterable, Optional
|
|
48
|
+
|
|
49
|
+
# Structural edge kinds that represent a real dependency between modules.
|
|
50
|
+
_STRUCTURAL_EDGE_KINDS = frozenset({"imports", "calls"})
|
|
51
|
+
|
|
52
|
+
# Graph-size thresholds for how much a metric can be trusted / how much mass it
|
|
53
|
+
# is allowed to contribute. A three-node toy graph must not speak with the same
|
|
54
|
+
# authority as a sixty-module one.
|
|
55
|
+
_MIN_NODES_FOR_METRICS = 4
|
|
56
|
+
_MIN_EDGES_FOR_METRICS = 3
|
|
57
|
+
_HIGH_CONF_NODES = 12
|
|
58
|
+
_MASS_SATURATION_NODES = 10.0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class GraphEvidence:
|
|
63
|
+
"""One topological fact, ready to be consumed by any scorer.
|
|
64
|
+
|
|
65
|
+
Archetype-agnostic: it says *what the graph looks like*, never *what the
|
|
66
|
+
system is*. ``value`` is the raw metric in [0,1]; the scorer decides which
|
|
67
|
+
archetype (if any) that value supports and with what weight."""
|
|
68
|
+
|
|
69
|
+
provider: str # always "module_graph"
|
|
70
|
+
kind: str # metric name, e.g. "scc_mass"
|
|
71
|
+
value: float # raw metric, 0..1
|
|
72
|
+
mass: float # 0..1 — topological coverage (graph size proxy)
|
|
73
|
+
confidence: str # low | medium | high — from graph size
|
|
74
|
+
detail: str # human-readable one-liner
|
|
75
|
+
|
|
76
|
+
def to_dict(self) -> dict:
|
|
77
|
+
return {
|
|
78
|
+
"provider": self.provider,
|
|
79
|
+
"kind": self.kind,
|
|
80
|
+
"value": round(self.value, 4),
|
|
81
|
+
"mass": round(self.mass, 4),
|
|
82
|
+
"confidence": self.confidence,
|
|
83
|
+
"detail": self.detail,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class GraphEvidenceResult:
|
|
89
|
+
"""Metrics + evidence produced from a single module graph."""
|
|
90
|
+
|
|
91
|
+
node_count: int
|
|
92
|
+
edge_count: int
|
|
93
|
+
metrics: dict[str, float] = field(default_factory=dict)
|
|
94
|
+
evidence: list[GraphEvidence] = field(default_factory=list)
|
|
95
|
+
available: bool = False # False when the graph is too small to trust
|
|
96
|
+
confidence: str = "low" # overall provider confidence
|
|
97
|
+
|
|
98
|
+
def metric(self, kind: str, default: float = 0.0) -> float:
|
|
99
|
+
return self.metrics.get(kind, default)
|
|
100
|
+
|
|
101
|
+
def to_dict(self) -> dict:
|
|
102
|
+
return {
|
|
103
|
+
"node_count": self.node_count,
|
|
104
|
+
"edge_count": self.edge_count,
|
|
105
|
+
"available": self.available,
|
|
106
|
+
"confidence": self.confidence,
|
|
107
|
+
"metrics": {k: round(v, 4) for k, v in self.metrics.items()},
|
|
108
|
+
"evidence": [e.to_dict() for e in self.evidence],
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class GraphEvidenceProvider:
|
|
113
|
+
"""Transforms a module graph (nodes + directed edges) into GraphEvidence.
|
|
114
|
+
|
|
115
|
+
Stateless. Accepts anything exposing ``nodes`` and ``edges`` (a
|
|
116
|
+
``schema.ModuleGraph`` does), so tests can hand it synthetic graphs without
|
|
117
|
+
building a repository."""
|
|
118
|
+
|
|
119
|
+
def analyze(
|
|
120
|
+
self,
|
|
121
|
+
graph: object,
|
|
122
|
+
*,
|
|
123
|
+
edge_kinds: Optional[Iterable[str]] = None,
|
|
124
|
+
) -> GraphEvidenceResult:
|
|
125
|
+
nodes, adjacency = self._project(graph, edge_kinds)
|
|
126
|
+
n = len(nodes)
|
|
127
|
+
edges = [(u, v) for u, outs in adjacency.items() for v in outs]
|
|
128
|
+
e = len(edges)
|
|
129
|
+
|
|
130
|
+
result = GraphEvidenceResult(node_count=n, edge_count=e)
|
|
131
|
+
if n < _MIN_NODES_FOR_METRICS or e < _MIN_EDGES_FOR_METRICS:
|
|
132
|
+
# Too small to say anything structural. Report nothing rather than
|
|
133
|
+
# asserting metrics on noise.
|
|
134
|
+
return result
|
|
135
|
+
|
|
136
|
+
result.available = True
|
|
137
|
+
result.confidence = "high" if n >= _HIGH_CONF_NODES else "medium"
|
|
138
|
+
topo_mass = min(1.0, n / _MASS_SATURATION_NODES)
|
|
139
|
+
|
|
140
|
+
in_deg, out_deg = self._degrees(nodes, edges)
|
|
141
|
+
scc_of, scc_sizes = self._tarjan_scc(nodes, adjacency)
|
|
142
|
+
instability = self._instability(nodes, in_deg, out_deg)
|
|
143
|
+
|
|
144
|
+
m: dict[str, float] = {}
|
|
145
|
+
# Only NON-TRIVIAL SCCs (size > 1) count: a pure DAG has none, a woven
|
|
146
|
+
# engine core has many. A single-node "component" is not a cycle.
|
|
147
|
+
cyclic_nodes = sum(sz for sz in scc_sizes.values() if sz > 1)
|
|
148
|
+
m["scc_mass"] = (cyclic_nodes / n) if n else 0.0
|
|
149
|
+
m["cyclic_density"] = self._cyclic_density(edges, scc_of, scc_sizes, e)
|
|
150
|
+
m["hub_concentration"] = (max(in_deg.values(), default=0) / e) if e else 0.0
|
|
151
|
+
m["module_centralization"] = self._centralization(in_deg, n)
|
|
152
|
+
m["downward_edge_ratio"] = self._downward_ratio(edges, instability)
|
|
153
|
+
m["dependency_inversion_ratio"] = self._inversion_ratio(edges, instability)
|
|
154
|
+
m["fan_in_gini"] = self._gini([in_deg[x] for x in nodes])
|
|
155
|
+
result.metrics = m
|
|
156
|
+
|
|
157
|
+
# One evidence object per metric.
|
|
158
|
+
details = {
|
|
159
|
+
"scc_mass": f"{m['scc_mass']:.0%} of modules sit inside an import cycle",
|
|
160
|
+
"cyclic_density": f"{m['cyclic_density']:.0%} of edges are back-edges inside a cycle",
|
|
161
|
+
"hub_concentration": f"top module absorbs {m['hub_concentration']:.0%} of all dependencies",
|
|
162
|
+
"module_centralization": f"in-degree centralization {m['module_centralization']:.2f} (1=star, 0=mesh)",
|
|
163
|
+
"downward_edge_ratio": f"{m['downward_edge_ratio']:.0%} of edges flow toward more-stable modules",
|
|
164
|
+
"dependency_inversion_ratio": f"{m['dependency_inversion_ratio']:.0%} of edges point into the stable core",
|
|
165
|
+
"fan_in_gini": f"in-degree inequality (Gini) {m['fan_in_gini']:.2f}",
|
|
166
|
+
}
|
|
167
|
+
result.evidence = [
|
|
168
|
+
GraphEvidence(
|
|
169
|
+
provider="module_graph", kind=k, value=m[k], mass=topo_mass,
|
|
170
|
+
confidence=result.confidence, detail=details[k],
|
|
171
|
+
)
|
|
172
|
+
for k in details
|
|
173
|
+
]
|
|
174
|
+
return result
|
|
175
|
+
|
|
176
|
+
# ── graph projection ───────────────────────────────────────────────────────
|
|
177
|
+
@staticmethod
|
|
178
|
+
def _project(
|
|
179
|
+
graph: object, edge_kinds: Optional[Iterable[str]]
|
|
180
|
+
) -> "tuple[list[str], dict[str, set[str]]]":
|
|
181
|
+
"""Reduce the graph to a deduped directed adjacency over the relevant
|
|
182
|
+
vertices. Prefers module-granularity nodes; falls back to all nodes."""
|
|
183
|
+
raw_nodes = list(getattr(graph, "nodes", []) or [])
|
|
184
|
+
raw_edges = list(getattr(graph, "edges", []) or [])
|
|
185
|
+
kinds = frozenset(edge_kinds) if edge_kinds is not None else _STRUCTURAL_EDGE_KINDS
|
|
186
|
+
|
|
187
|
+
module_ids = {getattr(nd, "id") for nd in raw_nodes if getattr(nd, "kind", None) == "module"}
|
|
188
|
+
# If the graph carries module nodes, restrict to them; otherwise use every
|
|
189
|
+
# node (synthetic test graphs, symbol-only graphs).
|
|
190
|
+
if module_ids:
|
|
191
|
+
allowed = module_ids
|
|
192
|
+
else:
|
|
193
|
+
allowed = {getattr(nd, "id") for nd in raw_nodes}
|
|
194
|
+
|
|
195
|
+
adjacency: dict[str, set[str]] = {nid: set() for nid in allowed}
|
|
196
|
+
for ed in raw_edges:
|
|
197
|
+
if getattr(ed, "kind", None) not in kinds:
|
|
198
|
+
continue
|
|
199
|
+
u = getattr(ed, "source", None)
|
|
200
|
+
v = getattr(ed, "target", None)
|
|
201
|
+
if u is None or v is None or u == v:
|
|
202
|
+
continue
|
|
203
|
+
if u not in allowed or v not in allowed:
|
|
204
|
+
continue
|
|
205
|
+
adjacency.setdefault(u, set()).add(v)
|
|
206
|
+
adjacency.setdefault(v, set())
|
|
207
|
+
nodes = sorted(adjacency.keys())
|
|
208
|
+
return nodes, adjacency
|
|
209
|
+
|
|
210
|
+
# ── metric primitives ──────────────────────────────────────────────────────
|
|
211
|
+
@staticmethod
|
|
212
|
+
def _degrees(
|
|
213
|
+
nodes: list[str], edges: list[tuple[str, str]]
|
|
214
|
+
) -> "tuple[dict[str, int], dict[str, int]]":
|
|
215
|
+
in_deg = {x: 0 for x in nodes}
|
|
216
|
+
out_deg = {x: 0 for x in nodes}
|
|
217
|
+
for u, v in edges:
|
|
218
|
+
out_deg[u] += 1
|
|
219
|
+
in_deg[v] += 1
|
|
220
|
+
return in_deg, out_deg
|
|
221
|
+
|
|
222
|
+
@staticmethod
|
|
223
|
+
def _tarjan_scc(
|
|
224
|
+
nodes: list[str], adjacency: dict[str, set[str]]
|
|
225
|
+
) -> "tuple[dict[str, int], dict[int, int]]":
|
|
226
|
+
"""Iterative Tarjan. Returns (node -> scc_id, scc_id -> size)."""
|
|
227
|
+
index_of: dict[str, int] = {}
|
|
228
|
+
low: dict[str, int] = {}
|
|
229
|
+
on_stack: set[str] = set()
|
|
230
|
+
stack: list[str] = []
|
|
231
|
+
scc_of: dict[str, int] = {}
|
|
232
|
+
sizes: dict[int, int] = {}
|
|
233
|
+
counter = 0
|
|
234
|
+
scc_id = 0
|
|
235
|
+
|
|
236
|
+
for root in nodes:
|
|
237
|
+
if root in index_of:
|
|
238
|
+
continue
|
|
239
|
+
# work stack of (node, iterator-position)
|
|
240
|
+
work: list[tuple[str, int]] = [(root, 0)]
|
|
241
|
+
while work:
|
|
242
|
+
v, pi = work[-1]
|
|
243
|
+
if pi == 0:
|
|
244
|
+
index_of[v] = low[v] = counter
|
|
245
|
+
counter += 1
|
|
246
|
+
stack.append(v)
|
|
247
|
+
on_stack.add(v)
|
|
248
|
+
succ = sorted(adjacency.get(v, ()))
|
|
249
|
+
if pi < len(succ):
|
|
250
|
+
work[-1] = (v, pi + 1)
|
|
251
|
+
w = succ[pi]
|
|
252
|
+
if w not in index_of:
|
|
253
|
+
work.append((w, 0))
|
|
254
|
+
elif w in on_stack:
|
|
255
|
+
low[v] = min(low[v], index_of[w])
|
|
256
|
+
continue
|
|
257
|
+
# done with v
|
|
258
|
+
if low[v] == index_of[v]:
|
|
259
|
+
size = 0
|
|
260
|
+
while True:
|
|
261
|
+
w = stack.pop()
|
|
262
|
+
on_stack.discard(w)
|
|
263
|
+
scc_of[w] = scc_id
|
|
264
|
+
size += 1
|
|
265
|
+
if w == v:
|
|
266
|
+
break
|
|
267
|
+
sizes[scc_id] = size
|
|
268
|
+
scc_id += 1
|
|
269
|
+
work.pop()
|
|
270
|
+
if work:
|
|
271
|
+
parent = work[-1][0]
|
|
272
|
+
low[parent] = min(low[parent], low[v])
|
|
273
|
+
return scc_of, sizes
|
|
274
|
+
|
|
275
|
+
@staticmethod
|
|
276
|
+
def _cyclic_density(
|
|
277
|
+
edges: list[tuple[str, str]], scc_of: dict[str, int],
|
|
278
|
+
scc_sizes: dict[int, int], e: int,
|
|
279
|
+
) -> float:
|
|
280
|
+
if not e:
|
|
281
|
+
return 0.0
|
|
282
|
+
intra = 0
|
|
283
|
+
for u, v in edges:
|
|
284
|
+
su = scc_of.get(u)
|
|
285
|
+
if su is not None and su == scc_of.get(v) and scc_sizes.get(su, 1) > 1:
|
|
286
|
+
intra += 1
|
|
287
|
+
return intra / e
|
|
288
|
+
|
|
289
|
+
@staticmethod
|
|
290
|
+
def _centralization(in_deg: dict[str, int], n: int) -> float:
|
|
291
|
+
"""Freeman in-degree centralization, normalized to [0,1]."""
|
|
292
|
+
if n < 2:
|
|
293
|
+
return 0.0
|
|
294
|
+
mx = max(in_deg.values(), default=0)
|
|
295
|
+
total_diff = sum(mx - d for d in in_deg.values())
|
|
296
|
+
denom = (n - 1) * (n - 1) # theoretical max for a directed star
|
|
297
|
+
return min(1.0, total_diff / denom) if denom else 0.0
|
|
298
|
+
|
|
299
|
+
@staticmethod
|
|
300
|
+
def _instability(
|
|
301
|
+
nodes: list[str], in_deg: dict[str, int], out_deg: dict[str, int]
|
|
302
|
+
) -> dict[str, float]:
|
|
303
|
+
"""Martin instability I = out / (in + out). Isolated nodes -> None-like
|
|
304
|
+
(excluded via absence)."""
|
|
305
|
+
inst: dict[str, float] = {}
|
|
306
|
+
for x in nodes:
|
|
307
|
+
total = in_deg[x] + out_deg[x]
|
|
308
|
+
if total > 0:
|
|
309
|
+
inst[x] = out_deg[x] / total
|
|
310
|
+
return inst
|
|
311
|
+
|
|
312
|
+
@staticmethod
|
|
313
|
+
def _downward_ratio(
|
|
314
|
+
edges: list[tuple[str, str]], instability: dict[str, float]
|
|
315
|
+
) -> float:
|
|
316
|
+
considered = 0
|
|
317
|
+
downward = 0
|
|
318
|
+
for u, v in edges:
|
|
319
|
+
if u in instability and v in instability:
|
|
320
|
+
considered += 1
|
|
321
|
+
# edge from less-stable (higher I) to more-stable (lower I)
|
|
322
|
+
if instability[u] >= instability[v]:
|
|
323
|
+
downward += 1
|
|
324
|
+
return (downward / considered) if considered else 0.0
|
|
325
|
+
|
|
326
|
+
@staticmethod
|
|
327
|
+
def _inversion_ratio(
|
|
328
|
+
edges: list[tuple[str, str]], instability: dict[str, float]
|
|
329
|
+
) -> float:
|
|
330
|
+
"""Fraction of edges whose target sits in the most-stable quartile."""
|
|
331
|
+
vals = sorted(instability.values())
|
|
332
|
+
if not vals or not edges:
|
|
333
|
+
return 0.0
|
|
334
|
+
q1 = vals[max(0, len(vals) // 4 - 1)]
|
|
335
|
+
into_core = sum(
|
|
336
|
+
1 for _u, v in edges
|
|
337
|
+
if v in instability and instability[v] <= q1
|
|
338
|
+
)
|
|
339
|
+
return into_core / len(edges)
|
|
340
|
+
|
|
341
|
+
@staticmethod
|
|
342
|
+
def _gini(values: list[int]) -> float:
|
|
343
|
+
vals = sorted(float(v) for v in values)
|
|
344
|
+
n = len(vals)
|
|
345
|
+
s = sum(vals)
|
|
346
|
+
if n == 0 or s == 0:
|
|
347
|
+
return 0.0
|
|
348
|
+
cum = 0.0
|
|
349
|
+
for i, v in enumerate(vals, start=1):
|
|
350
|
+
cum += i * v
|
|
351
|
+
# Gini = (2*sum(i*x_i) / (n*sum)) - (n+1)/n
|
|
352
|
+
return max(0.0, min(1.0, (2 * cum) / (n * s) - (n + 1) / n))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sourcecode
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.0
|
|
4
4
|
Summary: Persistent structural context and ultra-fast repeated analysis for AI coding agents
|
|
5
5
|
License-File: LICENSE
|
|
6
6
|
Keywords: agents,ai,codebase,context,developer-tools,llm
|
|
@@ -42,7 +42,7 @@ Description-Content-Type: text/markdown
|
|
|
42
42
|
|
|
43
43
|
**Context · Impact · Migration · Architecture · Review — everything from one structural model.**
|
|
44
44
|
|
|
45
|
-

|
|
46
46
|

|
|
47
47
|
|
|
48
48
|
> **ASK Engine** is the product. The CLI command is **`ask`**. The legacy **`sourcecode`**
|
|
@@ -84,7 +84,7 @@ brew tap haroundominique/sourcecode && brew install sourcecode
|
|
|
84
84
|
# pip / pipx
|
|
85
85
|
pipx install sourcecode # or: pip install sourcecode
|
|
86
86
|
|
|
87
|
-
ask version # ask 2.
|
|
87
|
+
ask version # ask 2.3.0
|
|
88
88
|
```
|
|
89
89
|
|
|
90
90
|
> **Package vs. command.** The install package is named `sourcecode` this release
|
|
@@ -1,15 +1,16 @@
|
|
|
1
|
-
sourcecode/__init__.py,sha256=
|
|
1
|
+
sourcecode/__init__.py,sha256=_9_i7ziVcM1wLhG9oULnIMnZhX9H1T1qmnmsiNwXBmg,308
|
|
2
2
|
sourcecode/adaptive_scanner.py,sha256=XffluXKzJUXrMtjEiAOnSNPZnztdIcts17T9ouHeID0,10521
|
|
3
|
+
sourcecode/archetype.py,sha256=ezeyCR4E0VeZUUBPwmsWUUPAhIlqwwkc8qDieUx2JWA,31087
|
|
3
4
|
sourcecode/architecture_analyzer.py,sha256=6_wC4TYuBYxaqckZS0I1vBSMRPeH2vzlDB48gXwOOd4,46627
|
|
4
|
-
sourcecode/architecture_summary.py,sha256=
|
|
5
|
+
sourcecode/architecture_summary.py,sha256=pkl6lIOnSy-poa9EzW43U0MkXhx2FDfAFh8pDXLpcTo,26630
|
|
5
6
|
sourcecode/ast_extractor.py,sha256=FEsYTsMCXbJfSYQZRk1k-I-PjUNGnKqO1IxMftsgA5U,51351
|
|
6
7
|
sourcecode/cache.py,sha256=1V3vsaODAa2UBJAC0xpvxpmRdriCezQx5Q8JCcfgziE,31892
|
|
7
8
|
sourcecode/call_surface.py,sha256=fiqYfHooxN1fX9oQoysq1LS3LoZcobhyUNjAGEZKpwk,4148
|
|
8
9
|
sourcecode/caller_metrics.py,sha256=HG8RCOkpzzsEGdnMRob6byrwjoj5__t0TEsBj4yR7ks,2574
|
|
9
10
|
sourcecode/canonical_ir.py,sha256=U69m8Vkr0p407xF1q5y1KguHXWEAubYw8NFHR9hDNJk,29178
|
|
10
11
|
sourcecode/cir_graphs.py,sha256=9G0HHj1kw2325IDyzo2OpX73BNswEckecf4MZUXB4JM,12078
|
|
11
|
-
sourcecode/classifier.py,sha256=
|
|
12
|
-
sourcecode/cli.py,sha256=
|
|
12
|
+
sourcecode/classifier.py,sha256=RhLMDR031FAiUcT3bsf2EL3KrFwl7KsWh_wXXR_Bumo,18729
|
|
13
|
+
sourcecode/cli.py,sha256=rK4umQmgajbQwuwmhhm6q898X-xaniHuRpXbUnW1WUk,296904
|
|
13
14
|
sourcecode/code_notes_analyzer.py,sha256=EJemNCNc9Dn-1RZYu-aNbK0ELzmsyC4s6FdHi3XyNEI,9392
|
|
14
15
|
sourcecode/confidence_analyzer.py,sha256=_jckZSxksV-OU38vbkxfVNBnWCtlCq8Vwfg23x1uspA,19054
|
|
15
16
|
sourcecode/context_graph.py,sha256=8MVDu06bPhvRDTgUqWEvRME2fOw34bFLf4OUILZd13I,43527
|
|
@@ -34,6 +35,7 @@ sourcecode/format_contract.py,sha256=1cTNqwP8geA2hbQoBHUPgX3_vSh3l8guJT_jmgEnFF8
|
|
|
34
35
|
sourcecode/fqn_utils.py,sha256=XLU7zDkNBXz_RZkIUNfpPmp1nekWtqP-fxV92tDV1vg,2158
|
|
35
36
|
sourcecode/git_analyzer.py,sha256=JStxTQXNjBWi_wLdwhsZs9mT-v50cSJIz4Agzn6Kh9I,13362
|
|
36
37
|
sourcecode/graph_analyzer.py,sha256=lp0eB1PWC20BYF-GpPhAyegRpKrUKgOmXZIcZSIX_Ks,65777
|
|
38
|
+
sourcecode/graph_evidence.py,sha256=rENNsYRZeNstX_ExNCLlbHJAruFQwxo5d00x6wO3xwI,15030
|
|
37
39
|
sourcecode/hibernate_strat.py,sha256=h0leIhlWvSjYq3F99LxvLIDLrJ-xPYxWAREG4LkqZ-4,61190
|
|
38
40
|
sourcecode/jdk_exports.py,sha256=fCrlwNAXUT9gge_joq6kMnY3zJxYB2pxqy-0w3o3MJI,874
|
|
39
41
|
sourcecode/license.py,sha256=keFuwNxdAtvK2Ds91Wl79GMYxuxWYnN5Wbw1qBpaoUI,24896
|
|
@@ -115,8 +117,8 @@ sourcecode/telemetry/consent.py,sha256=LIAO9ohJZF8OuZwM4u1VWtALlYfTCCKq4wV3Vwc7i
|
|
|
115
117
|
sourcecode/telemetry/events.py,sha256=LtzYfaX9Ilckj5PTvAcTpDa9mLqDsYPDUiDkRa58piY,2580
|
|
116
118
|
sourcecode/telemetry/filters.py,sha256=NHa5T-6DaZduQPFuC34jOqHWQgSizM-Ygq8aZ4j19ng,5834
|
|
117
119
|
sourcecode/telemetry/transport.py,sha256=4gGHsq0WeY9VywEZXA3vUxykfiYnw9uuqfjAAec7F8o,1681
|
|
118
|
-
sourcecode-2.
|
|
119
|
-
sourcecode-2.
|
|
120
|
-
sourcecode-2.
|
|
121
|
-
sourcecode-2.
|
|
122
|
-
sourcecode-2.
|
|
120
|
+
sourcecode-2.3.0.dist-info/METADATA,sha256=sOmWl4PHteHOjBgUxeKpVh6H6Hc2Yo-Zyrk3c62z1CE,10837
|
|
121
|
+
sourcecode-2.3.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
122
|
+
sourcecode-2.3.0.dist-info/entry_points.txt,sha256=-JEAdChrK5We51kZcb7OaDcyil-dHBjBPL-NhuO-QY8,89
|
|
123
|
+
sourcecode-2.3.0.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
|
|
124
|
+
sourcecode-2.3.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|