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 CHANGED
@@ -4,4 +4,4 @@ ASK Engine is the product. ``ask`` is the canonical CLI command; ``sourcecode``
4
4
  the legacy compatibility alias and the Python/PyPI package name. See
5
5
  docs/PRODUCT_IDENTITY.md (normative)."""
6
6
 
7
- __version__ = "2.2.0"
7
+ __version__ = "2.3.0"
@@ -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
- return "api"
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.2.0
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
- ![Version](https://img.shields.io/badge/version-2.2.0-blue)
45
+ ![Version](https://img.shields.io/badge/version-2.3.0-blue)
46
46
  ![Python](https://img.shields.io/badge/python-3.9%2B-green)
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.2.0
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=QOkIfC7KA2tM2ix9_wUgS6fHiERh2v_TTNhjw8RBb1o,308
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=UaxmUU77oTlWxttMU3c6PxHrU-Q6q5N31TeU_NWWTdU,24297
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=YTTCoRdcLEFRVcql9Ow1dE7eYQj0jq2rgx32bRDnb1k,13852
12
- sourcecode/cli.py,sha256=CKy6zdBfecnT6reYb749pLY4NqH1_Qy7c8DHnBWamrU,293593
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.2.0.dist-info/METADATA,sha256=GWUjIL4RNPYx8cMKSRHxzELL49K0anIx6qH8aHAsB3I,10837
119
- sourcecode-2.2.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
120
- sourcecode-2.2.0.dist-info/entry_points.txt,sha256=-JEAdChrK5We51kZcb7OaDcyil-dHBjBPL-NhuO-QY8,89
121
- sourcecode-2.2.0.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
122
- sourcecode-2.2.0.dist-info/RECORD,,
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,,