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.
@@ -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
- 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
@@ -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 + "\n", style="bold dim cyan")
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
- data = _extract_java_endpoints(target)
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
- report = run_migrate_check(file_list, target, min_severity=min_severity)
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
- cir = ContextGraph.build(file_list, target).cir
5233
- model = SpringSemanticModel.build(cir)
5234
- report = run_pr_impact(cir, changed_files, root=target, model=model)
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
- cir = ContextGraph.build(file_list, target).cir
5352
- model = SpringSemanticModel.build(cir)
5353
- explanation = explain_class(class_name, cir, model)
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
- unreferenced = [n for n in nodes if n["fqn"] not in dispatched_fqns]
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
- # BUG #6 (Broadleaf field test): make the in_degree metric self-describing
5951
- # so it is not confused with `explain`'s caller count. They differ by design.
5952
- "high_coupling_nodes_note": (
5953
- "in_degree = raw count of incoming graph edges across ALL edge types "
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; mutual=true marks a "
5982
- "bidirectional (cyclic) dependency — the actual tangle that blocks "
5983
- "independent module extraction. Empty means no cross-subsystem "
5984
- "structural coupling was detected."
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
- + "Cross-module tangles indicate coupling worth decomposing."
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)"),
@@ -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
- from sourcecode.path_filters import is_test_path as _is_test_path
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