sourcecode 2.1.0__py3-none-any.whl → 2.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sourcecode/__init__.py +1 -1
- sourcecode/archetype.py +605 -0
- sourcecode/architecture_summary.py +41 -0
- sourcecode/caller_metrics.py +42 -0
- sourcecode/classifier.py +94 -1
- sourcecode/cli.py +191 -29
- sourcecode/detectors/java.py +29 -1
- sourcecode/endpoint_metrics.py +42 -0
- sourcecode/explain.py +2 -0
- sourcecode/graph_evidence.py +352 -0
- sourcecode/path_filters.py +2 -1
- sourcecode/repository_ir.py +50 -14
- sourcecode/semantic_integration_engine.py +18 -2
- sourcecode/serializer.py +23 -3
- sourcecode/spring_semantic.py +18 -5
- sourcecode/spring_tx_analyzer.py +21 -6
- {sourcecode-2.1.0.dist-info → sourcecode-2.3.0.dist-info}/METADATA +4 -4
- {sourcecode-2.1.0.dist-info → sourcecode-2.3.0.dist-info}/RECORD +21 -17
- {sourcecode-2.1.0.dist-info → sourcecode-2.3.0.dist-info}/WHEEL +0 -0
- {sourcecode-2.1.0.dist-info → sourcecode-2.3.0.dist-info}/entry_points.txt +0 -0
- {sourcecode-2.1.0.dist-info → sourcecode-2.3.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -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
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Single source of truth for the caller / fan-in metric reconciliation note.
|
|
2
|
+
|
|
3
|
+
Three commands report a "how many things reference this class" number, each
|
|
4
|
+
derived from a different graph projection. Read side by side they can look
|
|
5
|
+
contradictory (e.g. 215 vs 103 for the same class) — they are not. They count
|
|
6
|
+
different populations:
|
|
7
|
+
|
|
8
|
+
modernize.in_degree >= explain.incoming_callers ~= impact.stats.direct_caller_count
|
|
9
|
+
|
|
10
|
+
- ``modernize.in_degree`` counts graph *edges* (all edge types, symbol level, not
|
|
11
|
+
deduplicated per class) — a blast-radius ranking weight, always the largest, and
|
|
12
|
+
not a count of classes.
|
|
13
|
+
- ``explain.incoming_callers`` and ``impact.stats.direct_caller_count`` both count
|
|
14
|
+
*distinct dependent classes*. They normally match and are always the same order
|
|
15
|
+
of magnitude, but can differ by a small margin because each includes a slightly
|
|
16
|
+
different edge set (import-only references, DI-interface resolution). The
|
|
17
|
+
truncated ``impact.direct_callers`` array is a display sample, not the count.
|
|
18
|
+
|
|
19
|
+
Empirically verified across repositories (Broadleaf ExtensionManager: 215 / 103 /
|
|
20
|
+
103; petclinic entities & repositories: equal; occasional margin-of-one divergence
|
|
21
|
+
on classes with import-only references). The strict-equality claim was deliberately
|
|
22
|
+
NOT made — the note asserts only the robust ordering and the "same distinct-class
|
|
23
|
+
metric" relationship.
|
|
24
|
+
|
|
25
|
+
Every command that emits one of these numbers references this same string so the
|
|
26
|
+
explanation can never drift or contradict itself between commands.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
CALLER_METRIC_RECONCILIATION: str = (
|
|
30
|
+
"Fan-in / caller counts differ across commands BY DESIGN and are not "
|
|
31
|
+
"contradictory. For the same class the expected relationship is: "
|
|
32
|
+
"modernize.in_degree >= explain.incoming_callers ~= impact.stats.direct_caller_count. "
|
|
33
|
+
"modernize.in_degree = raw count of ALL incoming graph edges (imports, injects, "
|
|
34
|
+
"extends/implements, references, annotations), counted at symbol level and NOT "
|
|
35
|
+
"deduplicated per class — a blast-radius ranking weight, not a count of classes; "
|
|
36
|
+
"it is always the largest of the three. explain.incoming_callers and "
|
|
37
|
+
"impact.stats.direct_caller_count both count DISTINCT dependent classes "
|
|
38
|
+
"(deduplicated to class level); they normally match but can differ by a small "
|
|
39
|
+
"margin because each includes a slightly different edge set (e.g. import-only "
|
|
40
|
+
"references or DI-interface resolution). The top-level impact.direct_callers array "
|
|
41
|
+
"may be truncated for output size — use stats.direct_caller_count for the true total."
|
|
42
|
+
)
|
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
|
@@ -15,6 +15,7 @@ from sourcecode import __version__
|
|
|
15
15
|
from sourcecode.error_schema import INVALID_INPUT_CODE, build_error_envelope
|
|
16
16
|
from sourcecode.entrypoint_classifier import is_production_entry_point, normalize_entry_point
|
|
17
17
|
from sourcecode.progress import Progress
|
|
18
|
+
from sourcecode.caller_metrics import CALLER_METRIC_RECONCILIATION
|
|
18
19
|
from sourcecode.repository_ir import extract_java_endpoints as _extract_java_endpoints
|
|
19
20
|
|
|
20
21
|
|
|
@@ -245,6 +246,8 @@ _SUBCOMMANDS: frozenset[str] = frozenset(
|
|
|
245
246
|
"rename-class",
|
|
246
247
|
# Large file semantic chunking (BLOCKER-B)
|
|
247
248
|
"chunk-file",
|
|
249
|
+
# Experimental evidence-based archetype (parallel to legacy)
|
|
250
|
+
"archetype",
|
|
248
251
|
}
|
|
249
252
|
)
|
|
250
253
|
|
|
@@ -700,7 +703,8 @@ _WELCOME_CMDS = (
|
|
|
700
703
|
|
|
701
704
|
def _print_welcome_plain(tier: str) -> None:
|
|
702
705
|
"""Plain-text welcome — fallback when rich is unavailable."""
|
|
703
|
-
lines = ["", f" ASK Engine {__version__} · {tier} · CLI: ask",
|
|
706
|
+
lines = ["", f" ASK Engine {__version__} · {tier} · CLI: ask",
|
|
707
|
+
" Actionable Software Knowledge Engine", "",
|
|
704
708
|
" AI coding-agent context, instant.", "", " Get started:"]
|
|
705
709
|
for cmd, desc in _WELCOME_CMDS:
|
|
706
710
|
lines.append(f" {cmd.ljust(34)}{desc}")
|
|
@@ -747,8 +751,9 @@ def _print_welcome() -> None:
|
|
|
747
751
|
t.append(" ")
|
|
748
752
|
t.append(tier, style=tier_style)
|
|
749
753
|
t.append("\n")
|
|
750
|
-
# "ENGINE" label beneath the wordmark.
|
|
751
|
-
t.append(_WELCOME_ENGINE
|
|
754
|
+
# "ENGINE" label beneath the wordmark, with the acronym expansion.
|
|
755
|
+
t.append(_WELCOME_ENGINE, style="bold dim cyan")
|
|
756
|
+
t.append(" Actionable Software Knowledge\n", style="dim")
|
|
752
757
|
|
|
753
758
|
t.append("\nAI coding-agent context, instant.\n\n", style="white")
|
|
754
759
|
|
|
@@ -3911,7 +3916,12 @@ def endpoints_cmd(
|
|
|
3911
3916
|
)
|
|
3912
3917
|
raise typer.Exit(code=1)
|
|
3913
3918
|
|
|
3914
|
-
|
|
3919
|
+
_prog = Progress()
|
|
3920
|
+
_prog.start("scanning endpoints")
|
|
3921
|
+
try:
|
|
3922
|
+
data = _extract_java_endpoints(target)
|
|
3923
|
+
finally:
|
|
3924
|
+
_prog.finish()
|
|
3915
3925
|
|
|
3916
3926
|
# Update RIS api_surface section (non-fatal side-effect).
|
|
3917
3927
|
try:
|
|
@@ -4358,6 +4368,9 @@ def export_cmd(
|
|
|
4358
4368
|
)
|
|
4359
4369
|
raise typer.Exit(1)
|
|
4360
4370
|
|
|
4371
|
+
_prog = Progress()
|
|
4372
|
+
_prog.start("scanning repository")
|
|
4373
|
+
|
|
4361
4374
|
file_list = [
|
|
4362
4375
|
f for f in find_java_files(root)
|
|
4363
4376
|
if "/test/" not in f and "/tests/" not in f
|
|
@@ -4384,6 +4397,7 @@ def export_cmd(
|
|
|
4384
4397
|
_integrations,
|
|
4385
4398
|
endpoint_meta=_ep_data,
|
|
4386
4399
|
)
|
|
4400
|
+
_prog.finish()
|
|
4387
4401
|
output = _serialize_dict(data, format)
|
|
4388
4402
|
_emit_command_output(output, output_path, copy,
|
|
4389
4403
|
success_msg=f"C4 architecture export written to {output_path}")
|
|
@@ -4413,6 +4427,7 @@ def export_cmd(
|
|
|
4413
4427
|
ContextGraph.build(file_list, root)
|
|
4414
4428
|
).as_report(len(file_list))
|
|
4415
4429
|
|
|
4430
|
+
_prog.finish()
|
|
4416
4431
|
output = _serialize_dict(data, format)
|
|
4417
4432
|
_emit_command_output(output, output_path, copy,
|
|
4418
4433
|
success_msg=f"Export written to {output_path}")
|
|
@@ -4495,6 +4510,8 @@ def validation_cmd(
|
|
|
4495
4510
|
|
|
4496
4511
|
from sourcecode.context_graph import ContextGraph
|
|
4497
4512
|
from sourcecode.validation_surface import build_validation_surface
|
|
4513
|
+
_prog = Progress()
|
|
4514
|
+
_prog.start("mapping validation surface")
|
|
4498
4515
|
# Structural facts come from the ContextGraph — the single access layer.
|
|
4499
4516
|
_graph = ContextGraph.build_from_root(target)
|
|
4500
4517
|
data = build_validation_surface(target, graph=_graph)
|
|
@@ -4527,6 +4544,7 @@ def validation_cmd(
|
|
|
4527
4544
|
if _note:
|
|
4528
4545
|
data["note"] = _note
|
|
4529
4546
|
|
|
4547
|
+
_prog.finish()
|
|
4530
4548
|
output = _serialize_dict(data, format)
|
|
4531
4549
|
_summary = data.get("summary", {})
|
|
4532
4550
|
# Human-facing heads-up when the result is empty purely because no OpenAPI
|
|
@@ -4750,6 +4768,8 @@ def spring_audit_cmd(
|
|
|
4750
4768
|
success_msg=f"Spring audit written to {output_path}")
|
|
4751
4769
|
return
|
|
4752
4770
|
|
|
4771
|
+
_prog = Progress()
|
|
4772
|
+
_prog.start(f"auditing {len(file_list)} Java files")
|
|
4753
4773
|
cir = ContextGraph.build(file_list, target).cir
|
|
4754
4774
|
_model = SpringSemanticModel.build(cir)
|
|
4755
4775
|
|
|
@@ -4802,6 +4822,7 @@ def spring_audit_cmd(
|
|
|
4802
4822
|
except Exception:
|
|
4803
4823
|
pass
|
|
4804
4824
|
|
|
4825
|
+
_prog.finish()
|
|
4805
4826
|
if format == "github-comment":
|
|
4806
4827
|
output = _render_spring_audit_github_comment(combined, min_severity)
|
|
4807
4828
|
else:
|
|
@@ -4920,7 +4941,12 @@ def migrate_check_cmd(
|
|
|
4920
4941
|
|
|
4921
4942
|
_file_limitations: list[str] = []
|
|
4922
4943
|
file_list = find_java_files(target, limitations=_file_limitations)
|
|
4923
|
-
|
|
4944
|
+
_prog = Progress()
|
|
4945
|
+
_prog.start(f"checking migration ({len(file_list)} files)")
|
|
4946
|
+
try:
|
|
4947
|
+
report = run_migrate_check(file_list, target, min_severity=min_severity)
|
|
4948
|
+
finally:
|
|
4949
|
+
_prog.finish()
|
|
4924
4950
|
if _file_limitations:
|
|
4925
4951
|
report.limitations.extend(_file_limitations)
|
|
4926
4952
|
|
|
@@ -5079,6 +5105,8 @@ def impact_chain_cmd(
|
|
|
5079
5105
|
success_msg=f"Impact chain written to {output_path}")
|
|
5080
5106
|
return
|
|
5081
5107
|
|
|
5108
|
+
_prog = Progress()
|
|
5109
|
+
_prog.start(f"analyzing impact ({len(file_list)} files)")
|
|
5082
5110
|
cir = ContextGraph.build(file_list, target).cir
|
|
5083
5111
|
_model = SpringSemanticModel.build(cir)
|
|
5084
5112
|
|
|
@@ -5086,6 +5114,7 @@ def impact_chain_cmd(
|
|
|
5086
5114
|
from sourcecode.spring_event_topology import run_event_topology
|
|
5087
5115
|
evt_result = run_event_topology(cir, symbol, model=_model)
|
|
5088
5116
|
data = evt_result.to_dict()
|
|
5117
|
+
_prog.finish()
|
|
5089
5118
|
output = _serialize_dict(data, format)
|
|
5090
5119
|
_emit_command_output(
|
|
5091
5120
|
output, output_path, copy,
|
|
@@ -5101,6 +5130,7 @@ def impact_chain_cmd(
|
|
|
5101
5130
|
result = run_impact_chain(cir, symbol, depth=depth, root=target, model=_model)
|
|
5102
5131
|
|
|
5103
5132
|
data = result.to_dict()
|
|
5133
|
+
_prog.finish()
|
|
5104
5134
|
output = _serialize_dict(data, format)
|
|
5105
5135
|
_emit_command_output(
|
|
5106
5136
|
output, output_path, copy,
|
|
@@ -5229,9 +5259,14 @@ def pr_impact_cmd(
|
|
|
5229
5259
|
success_msg=f"PR impact report written to {output_path}")
|
|
5230
5260
|
return
|
|
5231
5261
|
|
|
5232
|
-
|
|
5233
|
-
|
|
5234
|
-
|
|
5262
|
+
_prog = Progress()
|
|
5263
|
+
_prog.start(f"analyzing PR impact ({len(file_list)} files)")
|
|
5264
|
+
try:
|
|
5265
|
+
cir = ContextGraph.build(file_list, target).cir
|
|
5266
|
+
model = SpringSemanticModel.build(cir)
|
|
5267
|
+
report = run_pr_impact(cir, changed_files, root=target, model=model)
|
|
5268
|
+
finally:
|
|
5269
|
+
_prog.finish()
|
|
5235
5270
|
|
|
5236
5271
|
output = _serialize_dict(report.to_dict(), "json") if format == "json" else report.render_text()
|
|
5237
5272
|
_emit_command_output(
|
|
@@ -5348,9 +5383,14 @@ def explain_cmd(
|
|
|
5348
5383
|
)
|
|
5349
5384
|
raise typer.Exit(code=1)
|
|
5350
5385
|
|
|
5351
|
-
|
|
5352
|
-
|
|
5353
|
-
|
|
5386
|
+
_prog = Progress()
|
|
5387
|
+
_prog.start(f"explaining {class_name} ({len(file_list)} files)")
|
|
5388
|
+
try:
|
|
5389
|
+
cir = ContextGraph.build(file_list, target).cir
|
|
5390
|
+
model = SpringSemanticModel.build(cir)
|
|
5391
|
+
explanation = explain_class(class_name, cir, model)
|
|
5392
|
+
finally:
|
|
5393
|
+
_prog.finish()
|
|
5354
5394
|
|
|
5355
5395
|
if format == "json":
|
|
5356
5396
|
output = _json.dumps(explanation.to_dict(), indent=2, ensure_ascii=False)
|
|
@@ -5663,7 +5703,56 @@ def _partition_static_unreferenced(nodes: list[dict], root: Path) -> tuple[list[
|
|
|
5663
5703
|
if files_scanned >= _CONFIG_SCAN_MAX_FILES or not unresolved_simple:
|
|
5664
5704
|
break
|
|
5665
5705
|
|
|
5666
|
-
|
|
5706
|
+
# 3. Nested-type qualified-reference scan (SC-2 residual). The static call-graph
|
|
5707
|
+
# does not emit an edge for a nested-type member access in ordinary method-body
|
|
5708
|
+
# code — e.g. `PropertyType.AdminGroupPresentation.NAME` credits the enclosing
|
|
5709
|
+
# type, not the nested holder — so a referenced nested class can look
|
|
5710
|
+
# zero-degree. When a candidate is a nested type (its second-to-last FQN segment
|
|
5711
|
+
# is an UpperCamel enclosing type) and its qualified `Outer.Nested` form appears
|
|
5712
|
+
# in some OTHER Java source, it is statically referenced: neither dead nor
|
|
5713
|
+
# framework-dispatched. Structural, name-agnostic (pattern derived from the
|
|
5714
|
+
# candidate's own FQN).
|
|
5715
|
+
import re as _re_nested
|
|
5716
|
+
statically_referenced: set[str] = set()
|
|
5717
|
+
nested_patterns: dict[str, "tuple"] = {}
|
|
5718
|
+
for n in nodes:
|
|
5719
|
+
if n["fqn"] in dispatched_fqns:
|
|
5720
|
+
continue
|
|
5721
|
+
parts = (n.get("fqn") or "").split(".")
|
|
5722
|
+
if len(parts) >= 2 and parts[-2][:1].isupper():
|
|
5723
|
+
outer, nested = parts[-2], parts[-1]
|
|
5724
|
+
nested_patterns[n["fqn"]] = (
|
|
5725
|
+
_re_nested.compile(r"\b" + _re_nested.escape(outer) + r"\." + _re_nested.escape(nested) + r"\b"),
|
|
5726
|
+
n.get("source_file") or "",
|
|
5727
|
+
)
|
|
5728
|
+
if nested_patterns:
|
|
5729
|
+
files_scanned = 0
|
|
5730
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
5731
|
+
dirnames[:] = [d for d in dirnames
|
|
5732
|
+
if d not in {".git", "build", "out", "target", "node_modules", ".gradle"}]
|
|
5733
|
+
for fname in filenames:
|
|
5734
|
+
if not fname.endswith(".java"):
|
|
5735
|
+
continue
|
|
5736
|
+
if files_scanned >= _CONFIG_SCAN_MAX_FILES or len(statically_referenced) == len(nested_patterns):
|
|
5737
|
+
break
|
|
5738
|
+
fpath = os.path.join(dirpath, fname)
|
|
5739
|
+
rel = os.path.relpath(fpath, root)
|
|
5740
|
+
try:
|
|
5741
|
+
with open(fpath, "r", encoding="utf-8", errors="replace") as fh:
|
|
5742
|
+
text = fh.read(_CONFIG_SCAN_MAX_BYTES)
|
|
5743
|
+
except OSError:
|
|
5744
|
+
continue
|
|
5745
|
+
files_scanned += 1
|
|
5746
|
+
for fqn, (pat, own) in nested_patterns.items():
|
|
5747
|
+
if fqn in statically_referenced or rel == own:
|
|
5748
|
+
continue
|
|
5749
|
+
if pat.search(text):
|
|
5750
|
+
statically_referenced.add(fqn)
|
|
5751
|
+
if files_scanned >= _CONFIG_SCAN_MAX_FILES or len(statically_referenced) == len(nested_patterns):
|
|
5752
|
+
break
|
|
5753
|
+
|
|
5754
|
+
unreferenced = [n for n in nodes
|
|
5755
|
+
if n["fqn"] not in dispatched_fqns and n["fqn"] not in statically_referenced]
|
|
5667
5756
|
dispatched = [n for n in nodes if n["fqn"] in dispatched_fqns]
|
|
5668
5757
|
return unreferenced, dispatched
|
|
5669
5758
|
|
|
@@ -5870,8 +5959,13 @@ def modernize_cmd(
|
|
|
5870
5959
|
"edge_count": _cnt,
|
|
5871
5960
|
"reverse_edge_count": _reverse,
|
|
5872
5961
|
# A mutual (cyclic) dependency is the actual "tangle" — both modules
|
|
5873
|
-
# reference each other, so neither can be extracted independently.
|
|
5962
|
+
# reference each other, so neither can be extracted independently. A
|
|
5963
|
+
# one-way (directional) dependency is normal layering, NOT a tangle:
|
|
5964
|
+
# TANGLE-1 (petclinic field test) surfaced owner→model / vet→model
|
|
5965
|
+
# (reverse_edge_count:0) being labelled "tangles" when they are plain
|
|
5966
|
+
# base-model usage. coupling_type states which it is, honestly.
|
|
5874
5967
|
"mutual": _reverse > 0,
|
|
5968
|
+
"coupling_type": "cyclic" if _reverse > 0 else "directional",
|
|
5875
5969
|
"example": _pair_example.get((_a, _b), ""),
|
|
5876
5970
|
})
|
|
5877
5971
|
# Surface mutual tangles first (highest decomposition risk), then by strength.
|
|
@@ -5947,17 +6041,10 @@ def modernize_cmd(
|
|
|
5947
6041
|
{"fqn": n["fqn"], "in_degree": n.get("in_degree", 0), "role": n.get("role", "other")}
|
|
5948
6042
|
for n in coupling_nodes
|
|
5949
6043
|
],
|
|
5950
|
-
#
|
|
5951
|
-
# so it is not confused with `explain`'s caller
|
|
5952
|
-
|
|
5953
|
-
|
|
5954
|
-
"(imports, injects, extends/implements, references, annotations), "
|
|
5955
|
-
"counted at symbol level. This is deliberately larger than and NOT "
|
|
5956
|
-
"equal to `ask explain`'s caller count, which reports DISTINCT "
|
|
5957
|
-
"dependent classes (DI dependents + reverse-call-graph callers, "
|
|
5958
|
-
"deduplicated to class level). Use in_degree for blast-radius ranking, "
|
|
5959
|
-
"explain's caller list for the concrete dependents to inspect."
|
|
5960
|
-
),
|
|
6044
|
+
# SC-6 (Broadleaf field test): make the in_degree metric self-describing
|
|
6045
|
+
# so it is not confused with `explain`'s / `impact`'s caller counts. All
|
|
6046
|
+
# three share one reconciliation note so they can never contradict.
|
|
6047
|
+
"high_coupling_nodes_note": CALLER_METRIC_RECONCILIATION,
|
|
5961
6048
|
"statically_unreferenced": [
|
|
5962
6049
|
{"fqn": n["fqn"], "type": n.get("type", ""), "role": n.get("role", "other")}
|
|
5963
6050
|
for n in dead_zones
|
|
@@ -5978,10 +6065,12 @@ def modernize_cmd(
|
|
|
5978
6065
|
"cross_module_tangles_note": (
|
|
5979
6066
|
"Directed inter-subsystem coupling measured from structural graph "
|
|
5980
6067
|
"edges (imports/injects/extends/implements/references/calls). "
|
|
5981
|
-
"edge_count = edges from_package→to_package
|
|
5982
|
-
"
|
|
5983
|
-
"independent module extraction
|
|
5984
|
-
"
|
|
6068
|
+
"edge_count = edges from_package→to_package. coupling_type=cyclic "
|
|
6069
|
+
"(mutual=true) is a bidirectional dependency — the actual tangle "
|
|
6070
|
+
"that blocks independent module extraction; coupling_type="
|
|
6071
|
+
"directional (reverse_edge_count=0) is a normal one-way layered "
|
|
6072
|
+
"dependency, listed for coupling strength, NOT a tangle. Empty "
|
|
6073
|
+
"means no cross-subsystem structural coupling was detected."
|
|
5985
6074
|
),
|
|
5986
6075
|
# BUG-05 fix: don't recommend "Start with hotspot_candidates" when the list is empty.
|
|
5987
6076
|
"recommendation": (
|
|
@@ -5992,7 +6081,9 @@ def modernize_cmd(
|
|
|
5992
6081
|
)
|
|
5993
6082
|
+ "statically_unreferenced lists classes with no Java callers — review "
|
|
5994
6083
|
+ "for framework dispatch (XML/reflection/SPI) before removing. "
|
|
5995
|
-
+ "
|
|
6084
|
+
+ "In cross_module_tangles, coupling_type=cyclic entries are the "
|
|
6085
|
+
+ "real tangles to decompose first; directional entries are normal "
|
|
6086
|
+
+ "layering ranked by coupling strength."
|
|
5996
6087
|
),
|
|
5997
6088
|
}
|
|
5998
6089
|
|
|
@@ -6333,6 +6424,77 @@ def config_cmd() -> None:
|
|
|
6333
6424
|
|
|
6334
6425
|
# ── cold-start (RIS bootstrap for external MCP and agents) ───────────────────
|
|
6335
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
|
+
|
|
6336
6498
|
@app.command("cold-start")
|
|
6337
6499
|
def cold_start_cmd(
|
|
6338
6500
|
path: Path = typer.Argument(Path("."), help="Repository path (default: current directory)"),
|
sourcecode/detectors/java.py
CHANGED
|
@@ -176,6 +176,28 @@ class JavaDetector(AbstractDetector):
|
|
|
176
176
|
frameworks = [f for f in frameworks if f.name != "MyBatis"]
|
|
177
177
|
|
|
178
178
|
entry_points = self._collect_entry_points(context)
|
|
179
|
+
|
|
180
|
+
# C1 (Eureka field test): a repo whose REST surface is JAX-RS / Jakarta
|
|
181
|
+
# REST — declared on the source via @Path/@GET, not through a web framework
|
|
182
|
+
# coordinate in the build manifest — must still surface that in the headline
|
|
183
|
+
# stack. Eureka wires JAX-RS through Jersey 1.x (com.sun.jersey) + jsr311-api
|
|
184
|
+
# + Guice, none of which the manifest-coordinate scan models, and its child
|
|
185
|
+
# build.gradle files are not scanned at all — yet it ships 18 @Path resource
|
|
186
|
+
# classes. Without this the headline read "Java library / package" while the
|
|
187
|
+
# endpoints command listed a full JAX-RS API. When the annotation scan found
|
|
188
|
+
# JAX-RS entry points and no Jakarta-EE framework was inferred from the
|
|
189
|
+
# manifest, surface it so the stack (and project_type) reflect the REST API
|
|
190
|
+
# the endpoints already prove. Structural (VAI): reasons from the JAX-RS
|
|
191
|
+
# entry points found in source, not from a vendor dependency-name allowlist.
|
|
192
|
+
# detected_via carries no path form, so it is treated as repo-wide evidence
|
|
193
|
+
# (never localized to an adapter submodule).
|
|
194
|
+
if not any(f.name == "Jakarta EE" for f in frameworks) and any(
|
|
195
|
+
ep.kind in ("jax_rs_controller", "jax_rs_provider") for ep in entry_points
|
|
196
|
+
):
|
|
197
|
+
frameworks.append(FrameworkDetection(
|
|
198
|
+
name="Jakarta EE", source="annotation", detected_via=["jax_rs_endpoints"],
|
|
199
|
+
))
|
|
200
|
+
|
|
179
201
|
transactional_classes = self._collect_transactional_classes(context, all_paths)
|
|
180
202
|
stack = StackDetection(
|
|
181
203
|
stack="java",
|
|
@@ -459,7 +481,13 @@ class JavaDetector(AbstractDetector):
|
|
|
459
481
|
# 1. @SpringBootApplication entry: Application.java / Main.java by name
|
|
460
482
|
# Exclude test trees: test helpers like AdminApplication.java in
|
|
461
483
|
# integration/src/test/java/ must not be treated as production entrypoints.
|
|
462
|
-
|
|
484
|
+
# B2 (openmrs field test): exclude not just src/test/ trees but whole
|
|
485
|
+
# test-harness MODULES whose sources sit under src/main — e.g. openmrs'
|
|
486
|
+
# `test-suite/module/omod/src/main/java/.../TestModuleController.java`, a
|
|
487
|
+
# scaffolding @Controller (`GET /module/testmodule/hello`) that a narrow
|
|
488
|
+
# src/test/ check misses, so it surfaced as a production entry point. The
|
|
489
|
+
# broader is_test_or_fixture_path catches test-suite / *-test / *-it modules.
|
|
490
|
+
from sourcecode.path_filters import is_test_or_fixture_path as _is_test_path
|
|
463
491
|
# BUG #3 (Alfresco field test): a `*Application.java` / `*Main.java` NAME does
|
|
464
492
|
# not make a file a bootstrap entry point. Alfresco has XSD-generated JAXB
|
|
465
493
|
# `Application.java` model classes (no main(), no bootstrap annotation) in a
|