sourcecode 1.71.0__tar.gz → 1.73.0__tar.gz

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.
Files changed (124) hide show
  1. {sourcecode-1.71.0 → sourcecode-1.73.0}/CHANGELOG.md +114 -0
  2. {sourcecode-1.71.0 → sourcecode-1.73.0}/PKG-INFO +1 -1
  3. {sourcecode-1.71.0 → sourcecode-1.73.0}/pyproject.toml +1 -1
  4. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/__init__.py +1 -1
  5. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/canonical_ir.py +14 -0
  6. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/cli.py +111 -19
  7. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/detectors/java.py +99 -8
  8. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/detectors/parsers.py +23 -0
  9. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/explain.py +7 -2
  10. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/integration_detector.py +54 -0
  11. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/migrate_check.py +54 -13
  12. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/repository_ir.py +346 -10
  13. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/serializer.py +6 -1
  14. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/spring_security_audit.py +11 -0
  15. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/spring_tx_analyzer.py +70 -9
  16. {sourcecode-1.71.0 → sourcecode-1.73.0}/.github/workflows/build-windows.yml +0 -0
  17. {sourcecode-1.71.0 → sourcecode-1.73.0}/.gitignore +0 -0
  18. {sourcecode-1.71.0 → sourcecode-1.73.0}/.ruff.toml +0 -0
  19. {sourcecode-1.71.0 → sourcecode-1.73.0}/CONTRIBUTING.md +0 -0
  20. {sourcecode-1.71.0 → sourcecode-1.73.0}/LICENSE +0 -0
  21. {sourcecode-1.71.0 → sourcecode-1.73.0}/README.md +0 -0
  22. {sourcecode-1.71.0 → sourcecode-1.73.0}/SECURITY.md +0 -0
  23. {sourcecode-1.71.0 → sourcecode-1.73.0}/raw +0 -0
  24. {sourcecode-1.71.0 → sourcecode-1.73.0}/scripts/generate_jdk_exports.py +0 -0
  25. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/adaptive_scanner.py +0 -0
  26. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/architecture_analyzer.py +0 -0
  27. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/architecture_summary.py +0 -0
  28. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/ast_extractor.py +0 -0
  29. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/cache.py +0 -0
  30. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/cir_graphs.py +0 -0
  31. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/classifier.py +0 -0
  32. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/code_notes_analyzer.py +0 -0
  33. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/confidence_analyzer.py +0 -0
  34. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/context_scorer.py +0 -0
  35. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/context_summarizer.py +0 -0
  36. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/contract_model.py +0 -0
  37. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/contract_pipeline.py +0 -0
  38. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/coverage_parser.py +0 -0
  39. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/dependency_analyzer.py +0 -0
  40. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/detectors/__init__.py +0 -0
  41. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/detectors/base.py +0 -0
  42. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/detectors/csproj_parser.py +0 -0
  43. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/detectors/dart.py +0 -0
  44. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/detectors/dotnet.py +0 -0
  45. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/detectors/elixir.py +0 -0
  46. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/detectors/go.py +0 -0
  47. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/detectors/heuristic.py +0 -0
  48. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/detectors/hybrid.py +0 -0
  49. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/detectors/jvm_ext.py +0 -0
  50. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/detectors/nodejs.py +0 -0
  51. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/detectors/php.py +0 -0
  52. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/detectors/project.py +0 -0
  53. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/detectors/python.py +0 -0
  54. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/detectors/ruby.py +0 -0
  55. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/detectors/rust.py +0 -0
  56. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/detectors/systems.py +0 -0
  57. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/detectors/terraform.py +0 -0
  58. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/detectors/tooling.py +0 -0
  59. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/doc_analyzer.py +0 -0
  60. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/entrypoint_classifier.py +0 -0
  61. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/env_analyzer.py +0 -0
  62. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/error_schema.py +0 -0
  63. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/file_chunker.py +0 -0
  64. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/file_classifier.py +0 -0
  65. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/flow_analyzer.py +0 -0
  66. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/format_contract.py +0 -0
  67. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/fqn_utils.py +0 -0
  68. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/git_analyzer.py +0 -0
  69. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/graph_analyzer.py +0 -0
  70. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/hibernate_strat.py +0 -0
  71. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/jdk_exports.py +0 -0
  72. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/license.py +0 -0
  73. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/mcp/__init__.py +0 -0
  74. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/mcp/onboarding/__init__.py +0 -0
  75. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/mcp/onboarding/applier.py +0 -0
  76. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/mcp/onboarding/backup.py +0 -0
  77. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/mcp/onboarding/detector.py +0 -0
  78. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/mcp/onboarding/planner.py +0 -0
  79. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/mcp/orchestrator.py +0 -0
  80. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/mcp/registry.py +0 -0
  81. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/mcp/runner.py +0 -0
  82. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/mcp/server.py +0 -0
  83. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/mcp_nudge.py +0 -0
  84. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/metrics_analyzer.py +0 -0
  85. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/openapi_surface.py +0 -0
  86. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/output_budget.py +0 -0
  87. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/path_filters.py +0 -0
  88. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/pr_comment_renderer.py +0 -0
  89. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/pr_impact.py +0 -0
  90. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/prepare_context.py +0 -0
  91. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/progress.py +0 -0
  92. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/ranking_engine.py +0 -0
  93. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/redactor.py +0 -0
  94. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/relevance_scorer.py +0 -0
  95. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/rename_refactor.py +0 -0
  96. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/repo_classifier.py +0 -0
  97. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/ris.py +0 -0
  98. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/runtime_classifier.py +0 -0
  99. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/scanner.py +0 -0
  100. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/schema.py +0 -0
  101. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/security_config.py +0 -0
  102. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/semantic_analyzer.py +0 -0
  103. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/spring_event_topology.py +0 -0
  104. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/spring_findings.py +0 -0
  105. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/spring_impact.py +0 -0
  106. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/spring_model.py +0 -0
  107. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/spring_semantic.py +0 -0
  108. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/summarizer.py +0 -0
  109. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/telemetry/__init__.py +0 -0
  110. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/telemetry/config.py +0 -0
  111. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/telemetry/consent.py +0 -0
  112. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/telemetry/events.py +0 -0
  113. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/telemetry/filters.py +0 -0
  114. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/telemetry/transport.py +0 -0
  115. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/tree_utils.py +0 -0
  116. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/validation_surface.py +0 -0
  117. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/version_check.py +0 -0
  118. {sourcecode-1.71.0 → sourcecode-1.73.0}/src/sourcecode/workspace.py +0 -0
  119. {sourcecode-1.71.0 → sourcecode-1.73.0}/supabase/functions/README.md +0 -0
  120. {sourcecode-1.71.0 → sourcecode-1.73.0}/supabase/functions/get-license/index.ts +0 -0
  121. {sourcecode-1.71.0 → sourcecode-1.73.0}/supabase/functions/lemonsqueezy-webhook/index.ts +0 -0
  122. {sourcecode-1.71.0 → sourcecode-1.73.0}/supabase/functions/telemetry/index.ts +0 -0
  123. {sourcecode-1.71.0 → sourcecode-1.73.0}/supabase/sql/license_event_ordering.sql +0 -0
  124. {sourcecode-1.71.0 → sourcecode-1.73.0}/supabase/sql/telemetry_events.sql +0 -0
@@ -1,5 +1,119 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.73.0] — 2026-07-01
4
+
5
+ ### Fixed — audit reliability on a large non-Boot Spring monolith (Alfresco Community Repo field test)
6
+
7
+ A real architecture audit of **alfresco-community-repo** (`fe6dd26c3f`; 6,549
8
+ classes, 24 Maven modules, Spring 7 **without Spring Boot**, iBatis persistence,
9
+ Camel/ActiveMQ messaging, Acegi-derived security, WebScript REST surface)
10
+ surfaced seven defects — each capable of leading an automated consultant to a
11
+ false conclusion. All fixed at root cause with regression coverage and
12
+ re-verified on the live repo.
13
+
14
+ - **`--compact` did not resolve `language_version` from a Maven property (BUG #1).**
15
+ Alfresco declares `<maven.compiler.source>${java.version}</...>` chained to
16
+ `<java.version>21</java.version>`; the Java-stack detector picked the first key
17
+ and emitted the literal `${java.version}` while `migrate-check` resolved it. A
18
+ shared `substitute_maven_properties` resolver (in `detectors/parsers.py`) now
19
+ backs BOTH paths — `--compact` reports `21`.
20
+ - **`explain --output` wrote Markdown to a `.json` file with no warning (BUG #2).**
21
+ Broke any consumer assuming JSON parity across subcommands. `explain` now infers
22
+ JSON from a `.json` output extension (an explicit `--format` always wins), and
23
+ `--help` documents that its default output is human-readable text/Markdown.
24
+ - **`entry_points.bootstrap` false positives on `*Application.java` by name (BUG #3).**
25
+ Alfresco's XSD/JAXB-generated `Application.java` model classes (no `main()`, no
26
+ bootstrap annotation, in a WAR) were listed as application entry points. A file
27
+ now qualifies only with a real `main(String[])` or a bootstrap annotation
28
+ (`@SpringBootApplication` / WAR `SpringBootServletInitializer` / `@QuarkusMain`
29
+ / …); the serializer's name-based bootstrap fallback was removed.
30
+ - **"MyBatis" framework declared against the tool's own zero evidence (BUG #4).**
31
+ A `org.mybatis:mybatis` pom coordinate alone produced a "MyBatis" framework label
32
+ while the `mybatis` block reported `mapper_interfaces: 0, xml_files: 0` (Alfresco's
33
+ SQL layer is iBatis-style `*-SqlMap.xml`, surfaced separately). MyBatis is now
34
+ emitted only with real usage evidence — an `@Mapper` interface or a `*Mapper.xml`.
35
+ - **`export --integrations` missed ActiveMQ/Camel messaging (BUG #5).**
36
+ JMS detection recognized little beyond a direct `JmsTemplate`, reporting 1
37
+ integration on a repo with an ActiveMQ connection-factory config and Camel routes.
38
+ Added `ActiveMQSslConnectionFactory` / pooled / caching connection-factory tokens
39
+ and an Apache Camel route pass (literal `activemq:`/`jms:` endpoint URIs, plus
40
+ variable-URI `from()/to()` routes inside a `RouteBuilder` as honest low-confidence
41
+ `camel-route` messaging records). Alfresco JMS/messaging surface: 1 → 5.
42
+ - **`readiness_score` Hibernate false-positive — regression test (BUG #6).**
43
+ v1.63.0 once scored this Hibernate-free repo 42/100 with a phantom
44
+ `hibernate_rewrite` headline (~46 days). Already fixed by v1.72.0; added an
45
+ explicit regression test (`no Hibernate/JPA imports → hibernate.detected False,
46
+ headline_blocker never hibernate_rewrite`) so a future detector refactor cannot
47
+ reintroduce it.
48
+ - **`endpoints` / C4 `api_surface` returned 0 with no explanation (BUG #7).**
49
+ On an obvious API server (WebScripts: XML descriptor + handler class, not
50
+ `@RestController`), a bare `total: 0` reads as "no public API". `endpoints` now
51
+ emits a first-class `zero_result_reason` naming the routing patterns searched and
52
+ any non-Spring surface detected (Alfresco: 482 WebScript descriptors / 901 handler
53
+ classes), and the C4 export's `api_surface` carries the same explanation.
54
+
55
+ ## [1.72.0] — 2026-07-01
56
+
57
+ ### Fixed — correctness on a large real-world Spring Boot 3 monolith (Broadleaf Commerce CE field test)
58
+
59
+ A downstream architecture audit of **BroadleafCommerce** (`97705634ac`, branch
60
+ `develop-7.0.x`; ~3,000 Java classes, 13 Maven poms, Spring Boot 3.5 / Spring 6.2 /
61
+ Hibernate 5.6) hand-verified against source surfaced seven reproducible
62
+ correctness defects across `modernize`, `spring-audit`, `migrate-check`, and
63
+ `export --integrations`. Each fix targets the root cause and adds regression
64
+ coverage; all were re-verified against the live repo.
65
+
66
+ - **`spring-audit` TX-005 — 100% false-positive rate (4/4) on expected-exception idioms.**
67
+ The "swallowed exception in `@Transactional`" rule flagged the standard JPA
68
+ control-flow idiom (`Query.getSingleResult()` → `catch (NoResultException)` →
69
+ insert; tolerant insert → `catch (EntityExistsException)`) and catches carrying an
70
+ explicit `// do nothing — acceptable condition` intent comment. TX-005 now skips
71
+ catches whose caught type is a well-known expected-control-flow exception
72
+ (`NoResultException`, `NonUniqueResultException`, `EntityExistsException`,
73
+ `EmptyResultDataAccessException`, …) and catches whose body documents a deliberate
74
+ no-op, while still flagging a genuine unexpected-exception swallow.
75
+ - **`modernize` `statically_unreferenced` — ~95% false positives on annotation-constant nested classes.**
76
+ Two compounding defects: (a) the pre-scan fast path emitted nested types as
77
+ `pkg.Inner`, dropping the enclosing class, so distinct nested types with the same
78
+ simple name (Broadleaf has 25 `GroupName`s) collapsed onto one colliding FQN;
79
+ (b) static-constant reads inside annotation arguments
80
+ (`@AdminPresentation(group = GroupName.General)`, incl. the fully-qualified
81
+ `Outer.Nested.CONST` form and class-level annotations) were never counted as
82
+ references, so the constant holders read as zero-caller dead code. Now: nested
83
+ symbols are binary-qualified (`Outer.Inner`); a new `references` edge pass resolves
84
+ annotation-value reads (bare, inherited-nested via supertypes, and qualified
85
+ chains) in both parsed and pre-scan-skipped files. Presentation-holder false
86
+ positives on Broadleaf went 11→0 (the survivors are genuinely-unused declarations).
87
+ - **`modernize` `cross_module_tangles` — re-emitted the subsystem list instead of coupling.**
88
+ The field echoed `{label, class_count, member_count}` per subsystem with no edge
89
+ data. It now measures real directed inter-subsystem coupling from structural graph
90
+ edges, with `edge_count`, `reverse_edge_count`, and `mutual` (bidirectional/cyclic
91
+ = the actual tangle) — e.g. Broadleaf `profile↔common` is now correctly flagged
92
+ mutual.
93
+ - **`migrate-check` MIG-031 — test-scope config counted as a production blocker + wrong explanation.**
94
+ A test-only `bl-applicationContext-test-security.xml` (under `src/main/resources`,
95
+ loaded only by test `@ContextConfiguration`) inflated `blocking_count`; XML config
96
+ with a `test` filename marker is now classified `code_context: "test"`
97
+ (blocking_count 1→0). The explanation was a static OR-template asserting both
98
+ triggers; it now reports only the sub-pattern that actually matched (auto-config
99
+ vs versioned legacy schema).
100
+ - **`export --integrations` — missed in-code Spring Security LDAP (false negative).**
101
+ Subclassing `LdapUserDetailsMapper` and using `DirContextOperations` — the
102
+ idiomatic Spring Security LDAP auth mapper — was undetected (Broadleaf reported 0
103
+ LDAP). Added `LdapUserDetailsMapper` / `DirContextOperations` / context-source
104
+ tokens; Broadleaf now reports 5 LDAP integration points.
105
+ - **`explain` vs `modernize` in-degree — silently contradictory numbers.**
106
+ The same class showed different fan-in in the two commands with no explanation.
107
+ Both metrics are now self-describing: `modernize.high_coupling_nodes` documents
108
+ `in_degree` as the raw all-edge-type incoming count; `explain` labels its value
109
+ as "N distinct caller classes".
110
+ - **`spring-audit` vs `endpoints` — unreconcilable endpoint counts.**
111
+ The canonical IR (feeding `spring-audit`, `impact`, `validation`) did not apply the
112
+ `endpoints` command's filter for framework dynamic-admin FQN-shaped paths, so its
113
+ count was silently inflated. The filter is now shared; `endpoints_analyzed` is
114
+ documented as the deduplicated canonical count (≤ `endpoints` total, difference =
115
+ multi-prefix collapse), reconciling 225 vs 220 on Broadleaf.
116
+
3
117
  ## [1.71.0] — 2026-07-01
4
118
 
5
119
  ### Fixed — framework-framing & migration reliability on a large non-Boot Spring repo (Jenkins core field test)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sourcecode
3
- Version: 1.71.0
3
+ Version: 1.73.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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sourcecode"
7
- version = "1.71.0"
7
+ version = "1.73.0"
8
8
  description = "Persistent structural context and ultra-fast repeated analysis for AI coding agents"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -1,3 +1,3 @@
1
1
  """sourcecode — Deterministic codebase context maps for AI coding agents."""
2
2
 
3
- __version__ = "1.71.0"
3
+ __version__ = "1.73.0"
@@ -16,6 +16,7 @@ from __future__ import annotations
16
16
 
17
17
  import hashlib
18
18
  import json
19
+ import re
19
20
  from dataclasses import dataclass, field
20
21
  from pathlib import Path
21
22
  from typing import Any, Optional
@@ -37,6 +38,11 @@ IR_SCHEMA_VERSION = "1.0.0"
37
38
  # Edge types excluded from reverse graph (mirrors repository_ir._REVERSE_EXCLUDE)
38
39
  _REVERSE_EXCLUDE: frozenset[str] = frozenset({"annotated_with", "mapped_to"})
39
40
 
41
+ # A route path that looks like a Java FQN is framework dynamic-admin routing, not a
42
+ # real REST endpoint (mirrors the filter in repository_ir.extract_java_endpoints so
43
+ # the CIR endpoint count reconciles with the `endpoints` command). See BUG #7.
44
+ _FQN_PATH_RE: re.Pattern = re.compile(r"/(org|com|net|io|edu)\.[a-z][a-z0-9]*\.[a-zA-Z]")
45
+
40
46
 
41
47
  # ---------------------------------------------------------------------------
42
48
  # CanonicalSecurity
@@ -324,9 +330,17 @@ def ir_dict_to_canonical(
324
330
  # Build canonical endpoints from route_surface — stable ordering
325
331
  route_surface = ir.get("route_surface") or []
326
332
  # Deduplicate by endpoint id (route_surface can have duplicates from multi-prefix)
333
+ # BUG #7 (Broadleaf field test): the `endpoints` command (extract_java_endpoints)
334
+ # drops routes whose path looks like a Java FQN — framework dynamic-admin routing
335
+ # (Broadleaf @AdminSection registers entity class FQNs as URL segments) is not a
336
+ # real REST surface. The CIR endpoint list feeds `spring-audit` (endpoints_analyzed),
337
+ # `impact` and `validation`; it must apply the SAME filter so its count reconciles
338
+ # with `endpoints` instead of being silently inflated by those pseudo-routes.
327
339
  _seen_ids: set[str] = set()
328
340
  raw_endpoints: list[CanonicalEndpoint] = []
329
341
  for r in route_surface:
342
+ if _FQN_PATH_RE.search(r.get("path", "") or ""):
343
+ continue
330
344
  ep = _route_to_canonical_endpoint(r)
331
345
  if ep.id not in _seen_ids:
332
346
  _seen_ids.add(ep.id)
@@ -4221,6 +4221,7 @@ def _build_c4_export(
4221
4221
  edges: "list[dict]",
4222
4222
  endpoints: "list[dict]",
4223
4223
  integrations: "dict",
4224
+ endpoint_meta: "Optional[dict]" = None,
4224
4225
  ) -> "dict":
4225
4226
  """Assemble a unified, tool-agnostic C4 architecture export + incremental manifest.
4226
4227
 
@@ -4236,6 +4237,15 @@ def _build_c4_export(
4236
4237
  # consumer counts/classifies modules instead of inferring them from leaf dirs.
4237
4238
  module_graph["module_roots"] = _detect_module_roots(by_directory)
4238
4239
  api_surface = _group_endpoints_by_controller(endpoints)
4240
+ # BUG #7 (Alfresco field test): carry the endpoint extractor's structured
4241
+ # zero-result explanation and non-Spring REST surface into api_surface, so a
4242
+ # C4 consumer seeing 0 controllers understands WHY (unrecognized routing, e.g.
4243
+ # WebScripts) instead of concluding the system has no public API.
4244
+ if endpoint_meta:
4245
+ if endpoint_meta.get("zero_result_reason"):
4246
+ api_surface["zero_result_reason"] = endpoint_meta["zero_result_reason"]
4247
+ if endpoint_meta.get("non_spring_rest_surface"):
4248
+ api_surface["non_spring_rest_surface"] = endpoint_meta["non_spring_rest_surface"]
4239
4249
  containers = _detect_containers(root)
4240
4250
 
4241
4251
  limitations: "list[str]" = []
@@ -4364,7 +4374,8 @@ def export_cmd(
4364
4374
  from sourcecode.repository_ir import extract_java_endpoints
4365
4375
  ir = build_repo_ir(file_list, root)
4366
4376
  graph = ir.get("graph", {})
4367
- endpoints = extract_java_endpoints(root).get("endpoints", [])
4377
+ _ep_data = extract_java_endpoints(root)
4378
+ endpoints = _ep_data.get("endpoints", [])
4368
4379
  data = _build_c4_export(
4369
4380
  root,
4370
4381
  file_list,
@@ -4372,6 +4383,7 @@ def export_cmd(
4372
4383
  graph.get("edges", []),
4373
4384
  endpoints,
4374
4385
  detect_integrations(file_list, root),
4386
+ endpoint_meta=_ep_data,
4375
4387
  )
4376
4388
  output = _serialize_dict(data, format)
4377
4389
  _emit_command_output(output, output_path, copy,
@@ -5215,14 +5227,16 @@ def explain_cmd(
5215
5227
  Path("."),
5216
5228
  help="Repository root (default: current directory)",
5217
5229
  ),
5218
- format: str = typer.Option(
5219
- "text", "--format", "-f",
5220
- help="Output format: text (default) or json.",
5221
- show_default=True,
5230
+ format: Optional[str] = typer.Option(
5231
+ None, "--format", "-f",
5232
+ help="Output format: text (default) or json. If unset and --output ends "
5233
+ "in .json, JSON is written automatically.",
5222
5234
  ),
5223
5235
  output_path: Optional[Path] = typer.Option(
5224
5236
  None, "--output", "-o",
5225
- help="Write output to a file instead of stdout.",
5237
+ help="Write output to a file instead of stdout. NOTE: the default format is "
5238
+ "human-readable text/Markdown, NOT JSON — pass --format json (or use a "
5239
+ ".json output path) for machine-readable output.",
5226
5240
  ),
5227
5241
  copy: bool = typer.Option(
5228
5242
  False, "--copy", "-c",
@@ -5231,6 +5245,11 @@ def explain_cmd(
5231
5245
  ) -> None:
5232
5246
  """Human-readable architectural summary for a class.
5233
5247
 
5248
+ \b
5249
+ OUTPUT FORMAT: defaults to text/Markdown (human-oriented). Unlike most
5250
+ subcommands this is NOT JSON by default — pass --format json, or give an
5251
+ --output path ending in .json, for a machine-readable envelope.
5252
+
5234
5253
  \b
5235
5254
  Generates a structured explanation derived entirely from static analysis:
5236
5255
  - Purpose and Spring stereotype
@@ -5278,6 +5297,15 @@ def explain_cmd(
5278
5297
  )
5279
5298
  raise typer.Exit(code=1)
5280
5299
 
5300
+ # BUG #2 (Alfresco field test): `explain -o out.json` previously wrote Markdown
5301
+ # to a .json file, breaking any programmatic consumer that assumed JSON parity
5302
+ # with the other subcommands. Infer JSON from a .json output extension when
5303
+ # --format is unset; an explicit --format always wins.
5304
+ if format is None:
5305
+ if output_path and str(output_path).lower().endswith(".json"):
5306
+ format = "json"
5307
+ else:
5308
+ format = "text"
5281
5309
  _enforce_format("explain", format)
5282
5310
 
5283
5311
  file_list = find_java_files(target)
@@ -5764,11 +5792,63 @@ def modernize_cmd(
5764
5792
  key=lambda h: (-h["hotspot_score"], h["fqn"]),
5765
5793
  )[:15]
5766
5794
 
5767
- # Cross-module tangles: subsystems with high member count
5768
- tangle_modules = sorted(
5769
- [s for s in subsystems if len(s.get("members") or []) >= 5],
5770
- key=lambda s: -len(s.get("members") or []),
5771
- )[:10]
5795
+ # Cross-module tangles: actual inter-subsystem coupling measured from the
5796
+ # dependency graph, NOT a re-labelled subsystem list. For every structural
5797
+ # edge whose endpoints live in two different subsystems we tally a directed
5798
+ # (from_subsystem → to_subsystem) coupling count; pairs coupled in BOTH
5799
+ # directions are the real tangles (bidirectional/cyclic module coupling).
5800
+ _graph_edges: list = (ir.get("graph") or {}).get("edges") or []
5801
+ # Structural coupling edge types. annotated_with / mapped_to / event edges are
5802
+ # excluded (metadata + separate topology, not module-decomposition coupling).
5803
+ _TANGLE_EDGE_TYPES = frozenset({
5804
+ "imports", "injects", "extends", "implements",
5805
+ "references", "calls", "instantiates", "returns",
5806
+ })
5807
+ _fqn_to_pkg: dict[str, str] = {}
5808
+ _pkg_to_label: dict[str, str] = {}
5809
+ for _s in subsystems:
5810
+ _pp = _s.get("package_prefix") or _s.get("pkg") or ""
5811
+ if not _pp:
5812
+ continue
5813
+ _pkg_to_label[_pp] = _s.get("label") or _s.get("name") or _pp
5814
+ for _m in (_s.get("members") or []):
5815
+ _fqn_to_pkg[_m] = _pp
5816
+
5817
+ from collections import Counter as _Counter
5818
+ _pair_counts: "_Counter[tuple[str, str]]" = _Counter()
5819
+ _pair_example: dict[tuple[str, str], str] = {}
5820
+ for _e in _graph_edges:
5821
+ if _e.get("type") not in _TANGLE_EDGE_TYPES:
5822
+ continue
5823
+ _a = _fqn_to_pkg.get(_e.get("from"))
5824
+ _b = _fqn_to_pkg.get(_e.get("to"))
5825
+ if not _a or not _b or _a == _b:
5826
+ continue
5827
+ _pair_counts[(_a, _b)] += 1
5828
+ _pair_example.setdefault((_a, _b), f"{_e.get('from')} → {_e.get('to')}")
5829
+
5830
+ _cross_module_tangles = []
5831
+ for (_a, _b), _cnt in sorted(
5832
+ _pair_counts.items(), key=lambda kv: (-kv[1], kv[0])
5833
+ ):
5834
+ _reverse = _pair_counts.get((_b, _a), 0)
5835
+ _cross_module_tangles.append({
5836
+ "from_subsystem": _pkg_to_label.get(_a, _a),
5837
+ "to_subsystem": _pkg_to_label.get(_b, _b),
5838
+ "from_package": _a,
5839
+ "to_package": _b,
5840
+ "edge_count": _cnt,
5841
+ "reverse_edge_count": _reverse,
5842
+ # A mutual (cyclic) dependency is the actual "tangle" — both modules
5843
+ # reference each other, so neither can be extracted independently.
5844
+ "mutual": _reverse > 0,
5845
+ "example": _pair_example.get((_a, _b), ""),
5846
+ })
5847
+ # Surface mutual tangles first (highest decomposition risk), then by strength.
5848
+ _cross_module_tangles.sort(
5849
+ key=lambda t: (not t["mutual"], -t["edge_count"], t["from_package"])
5850
+ )
5851
+ _cross_module_tangles = _cross_module_tangles[:15]
5772
5852
 
5773
5853
  _summary = {
5774
5854
  "total_classes": len([n for n in graph_nodes if n.get("type") in ("class", "interface")]),
@@ -5837,6 +5917,17 @@ def modernize_cmd(
5837
5917
  {"fqn": n["fqn"], "in_degree": n.get("in_degree", 0), "role": n.get("role", "other")}
5838
5918
  for n in coupling_nodes
5839
5919
  ],
5920
+ # BUG #6 (Broadleaf field test): make the in_degree metric self-describing
5921
+ # so it is not confused with `explain`'s caller count. They differ by design.
5922
+ "high_coupling_nodes_note": (
5923
+ "in_degree = raw count of incoming graph edges across ALL edge types "
5924
+ "(imports, injects, extends/implements, references, annotations), "
5925
+ "counted at symbol level. This is deliberately larger than and NOT "
5926
+ "equal to `sourcecode explain`'s caller count, which reports DISTINCT "
5927
+ "dependent classes (DI dependents + reverse-call-graph callers, "
5928
+ "deduplicated to class level). Use in_degree for blast-radius ranking, "
5929
+ "explain's caller list for the concrete dependents to inspect."
5930
+ ),
5840
5931
  "statically_unreferenced": [
5841
5932
  {"fqn": n["fqn"], "type": n.get("type", ""), "role": n.get("role", "other")}
5842
5933
  for n in dead_zones
@@ -5853,14 +5944,15 @@ def modernize_cmd(
5853
5944
  ],
5854
5945
  "subsystem_summary": _subsystem_summary,
5855
5946
  "subsystem_summary_note": _subsystem_summary_note,
5856
- "cross_module_tangles": [
5857
- {
5858
- "label": s.get("label") or s.get("name") or "",
5859
- "class_count": _class_count(s.get("members") or []),
5860
- "member_count": len(s.get("members") or []),
5861
- }
5862
- for s in tangle_modules
5863
- ],
5947
+ "cross_module_tangles": _cross_module_tangles,
5948
+ "cross_module_tangles_note": (
5949
+ "Directed inter-subsystem coupling measured from structural graph "
5950
+ "edges (imports/injects/extends/implements/references/calls). "
5951
+ "edge_count = edges from_package→to_package; mutual=true marks a "
5952
+ "bidirectional (cyclic) dependency — the actual tangle that blocks "
5953
+ "independent module extraction. Empty means no cross-subsystem "
5954
+ "structural coupling was detected."
5955
+ ),
5864
5956
  # BUG-05 fix: don't recommend "Start with hotspot_candidates" when the list is empty.
5865
5957
  "recommendation": (
5866
5958
  (
@@ -10,7 +10,11 @@ from sourcecode.detectors.base import (
10
10
  EntryPoint,
11
11
  StackDetection,
12
12
  )
13
- from sourcecode.detectors.parsers import read_text_lines, unique_strings
13
+ from sourcecode.detectors.parsers import (
14
+ read_text_lines,
15
+ substitute_maven_properties as _resolve_maven_property,
16
+ unique_strings,
17
+ )
14
18
  from sourcecode.schema import FrameworkDetection
15
19
  from sourcecode.tree_utils import flatten_file_tree
16
20
 
@@ -55,6 +59,14 @@ _JAX_RS_PROVIDER_RE = re.compile(r'@Provider\b')
55
59
 
56
60
  # --- CDI / Jakarta EE scopes ---
57
61
  _CDI_SCOPED_RE = re.compile(r'@(?:ApplicationScoped|RequestScoped|SessionScoped|Singleton|Dependent)\b')
62
+ # BUG #3: a genuine bootstrap entry declares a real main() or a bootstrap annotation
63
+ # / WAR initializer supertype — never merely a `*Application.java` filename.
64
+ _MAIN_METHOD_RE = re.compile(r'\bstatic\b[^;{}()]*\bmain\s*\(\s*(?:final\s+)?String')
65
+ _BOOTSTRAP_ANNOTATION_RE = re.compile(
66
+ r'@(?:SpringBootApplication|QuarkusMain|MicronautApplication|'
67
+ r'EnableAutoConfiguration|SpringBootConfiguration)\b'
68
+ r'|\bextends\s+SpringBootServletInitializer\b'
69
+ )
58
70
 
59
71
  # --- Keycloak / Quarkus SPI ---
60
72
  # Matches "implements EventListenerProvider" or "implements Foo, EventListenerProvider, Bar"
@@ -151,6 +163,18 @@ class JavaDetector(AbstractDetector):
151
163
  # Spring profiles — check src/main/options/, src/main/resources/
152
164
  spring_profiles = self._detect_spring_profiles(context.root, all_paths)
153
165
 
166
+ # BUG #4 (Alfresco field test): a `org.mybatis` coordinate in the pom is not
167
+ # proof the app USES MyBatis mappers. Alfresco declares org.mybatis:mybatis
168
+ # yet has zero @Mapper interfaces and zero *Mapper.xml — its SQL layer is
169
+ # iBatis-style *-SqlMap.xml (surfaced separately). Emitting "MyBatis" as an
170
+ # architecture framework then contradicted the tool's own evidence block
171
+ # (mapper_interfaces:0, xml_files:0) and mislabeled bean DTO mappers. Keep
172
+ # the label only with real usage evidence: a *Mapper.xml or an @Mapper interface.
173
+ if any(f.name == "MyBatis" for f in frameworks) and not self._has_mybatis_usage(
174
+ context.root, all_paths
175
+ ):
176
+ frameworks = [f for f in frameworks if f.name != "MyBatis"]
177
+
154
178
  entry_points = self._collect_entry_points(context)
155
179
  transactional_classes = self._collect_transactional_classes(context, all_paths)
156
180
  stack = StackDetection(
@@ -211,6 +235,17 @@ class JavaDetector(AbstractDetector):
211
235
  break
212
236
  break
213
237
 
238
+ # BUG #1 (Alfresco field test): a picked value can be a Maven property
239
+ # reference — Alfresco's <maven.compiler.source>${java.version}</...> chains
240
+ # to <java.version>21</java.version> in the SAME <properties> block. Resolve
241
+ # ${...} references against the pom's own properties so --compact reports the
242
+ # concrete version ("21") instead of the literal "${java.version}", matching
243
+ # what migrate-check already surfaces. Bounded iteration guards ref cycles.
244
+ if "language_version" in result:
245
+ result["language_version"] = _resolve_maven_property(
246
+ result["language_version"], props
247
+ )
248
+
214
249
  return result
215
250
 
216
251
  def _detect_spring_profiles(self, root: Path, all_paths: list[str]) -> list[str]:
@@ -425,15 +460,25 @@ class JavaDetector(AbstractDetector):
425
460
  # Exclude test trees: test helpers like AdminApplication.java in
426
461
  # integration/src/test/java/ must not be treated as production entrypoints.
427
462
  from sourcecode.path_filters import is_test_path as _is_test_path
463
+ # BUG #3 (Alfresco field test): a `*Application.java` / `*Main.java` NAME does
464
+ # not make a file a bootstrap entry point. Alfresco has XSD-generated JAXB
465
+ # `Application.java` model classes (no main(), no bootstrap annotation) in a
466
+ # WAR with no own entry point — flagging them as `application` polluted the
467
+ # executive summary. Emit `kind="application"` ONLY when the file actually
468
+ # declares a `main(String[])` method or a bootstrap annotation.
428
469
  app_candidates = [
429
- p for p in all_java
430
- if p.endswith(("Application.java", "Main.java"))
431
- and not _is_test_path(p)
432
- ]
433
- entry_points: list[EntryPoint] = [
434
- EntryPoint(path=p, stack="java", kind="application", source="manifest")
435
- for p in unique_strings(app_candidates)
470
+ p for p in unique_strings([
471
+ q for q in all_java
472
+ if q.endswith(("Application.java", "Main.java")) and not _is_test_path(q)
473
+ ])
436
474
  ]
475
+ entry_points: list[EntryPoint] = []
476
+ for p in app_candidates:
477
+ verified = self._verify_bootstrap_entry(context.root / p)
478
+ if verified:
479
+ entry_points.append(EntryPoint(
480
+ path=p, stack="java", kind="application", source=verified,
481
+ ))
437
482
 
438
483
  # 2. Annotation-based scan: @RestController, @WebFilter, FilterRegistrationBean
439
484
  # Prioritize Controller-named files so all REST controllers are detected
@@ -481,6 +526,52 @@ class JavaDetector(AbstractDetector):
481
526
  unique_eps.append(ep)
482
527
  return unique_eps
483
528
 
529
+ def _has_mybatis_usage(self, root: Path, all_paths: list[str]) -> bool:
530
+ """True when the repo shows real MyBatis usage, not just a pom coordinate.
531
+
532
+ Evidence = a *Mapper.xml file OR a *Mapper.java carrying the MyBatis
533
+ @Mapper annotation. iBatis-style *-SqlMap.xml is deliberately NOT counted
534
+ here — it is a distinct legacy pattern surfaced elsewhere (BUG #4).
535
+ """
536
+ if any(p.endswith("Mapper.xml") for p in all_paths):
537
+ return True
538
+ from sourcecode.path_filters import is_test_path as _is_test_path
539
+ mapper_java = [
540
+ p for p in all_paths
541
+ if p.endswith("Mapper.java") and not _is_test_path(p)
542
+ ]
543
+ for rel in mapper_java[:_MAX_JAVA_ENTRY_SCAN]:
544
+ try:
545
+ abs_path = root / rel
546
+ if abs_path.stat().st_size > _MAX_FILE_SIZE:
547
+ continue
548
+ content = abs_path.read_text(encoding="utf-8", errors="replace")
549
+ except OSError:
550
+ continue
551
+ if "org.apache.ibatis.annotations.Mapper" in content or "@Mapper" in content:
552
+ return True
553
+ return False
554
+
555
+ def _verify_bootstrap_entry(self, abs_path: Path) -> "str | None":
556
+ """Return the evidence source when *abs_path* is a real bootstrap entry.
557
+
558
+ A file is a genuine application entry point only if it declares a
559
+ `main(String[])` method OR carries a recognized bootstrap annotation /
560
+ WAR initializer supertype. Returns "main_method" / "bootstrap_annotation"
561
+ accordingly, else None (name alone is never sufficient — see BUG #3).
562
+ """
563
+ try:
564
+ if abs_path.stat().st_size > _MAX_FILE_SIZE:
565
+ return None
566
+ content = abs_path.read_text(encoding="utf-8", errors="replace")
567
+ except OSError:
568
+ return None
569
+ if _BOOTSTRAP_ANNOTATION_RE.search(content):
570
+ return "bootstrap_annotation"
571
+ if _MAIN_METHOD_RE.search(content):
572
+ return "main_method"
573
+ return None
574
+
484
575
  def _augment_deep_java_controllers(self, context: DetectionContext, all_java: list[str]) -> None:
485
576
  """Scan standard Java source roots for *Controller*.java files not in all_java.
486
577
 
@@ -1,10 +1,33 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ import re
4
5
  from collections.abc import Iterable
5
6
  from pathlib import Path
6
7
  from typing import Any, Optional
7
8
 
9
+ _MAVEN_PROP_REF_RE = re.compile(r'\$\{([\w.\-]+)\}')
10
+
11
+
12
+ def substitute_maven_properties(text: str, props: "dict[str, str]") -> str:
13
+ """Resolve Maven ${prop} references in *text* against *props*.
14
+
15
+ Shared single source of truth for Maven property resolution across
16
+ subcommands (BUG #1, Alfresco field test): both the --compact Java-stack
17
+ detector and migrate-check must resolve `${java.version}` → `21` identically.
18
+ Multi-level references (${a} where a=${b}) resolve in up to 3 passes; a
19
+ reference with no matching property is left verbatim (honest, not blanked).
20
+ """
21
+ if not props or "${" not in text:
22
+ return text
23
+ resolved = text
24
+ for _ in range(3):
25
+ new = _MAVEN_PROP_REF_RE.sub(lambda m: props.get(m.group(1), m.group(0)), resolved)
26
+ if new == resolved:
27
+ break
28
+ resolved = new
29
+ return resolved
30
+
8
31
 
9
32
  def load_json_file(path: Path) -> Optional[dict[str, Any]]:
10
33
  """Carga un fichero JSON sin lanzar si el contenido es invalido."""
@@ -298,10 +298,15 @@ def _structural_purpose(
298
298
  head = f"Likely {role} (no DI annotations found)" if role else \
299
299
  "Likely a structurally significant class (no DI annotations found)"
300
300
  signals: list[str] = []
301
+ # BUG #6 (Broadleaf field test): this counts DISTINCT dependent classes (DI
302
+ # dependents + reverse-call-graph callers, deduped to class level), which is a
303
+ # different metric from modernize's node.in_degree (raw incoming-edge count
304
+ # across ALL edge types, symbol-level). Name it explicitly so the two numbers
305
+ # are not read as a contradiction when cross-referenced.
301
306
  if in_degree >= 3:
302
- signals.append(f"high in-degree ({in_degree})")
307
+ signals.append(f"high fan-in ({in_degree} distinct caller classes)")
303
308
  elif in_degree:
304
- signals.append(f"in-degree {in_degree}")
309
+ signals.append(f"{in_degree} distinct caller class{'es' if in_degree != 1 else ''}")
305
310
  if lifecycle:
306
311
  signals.append(f"lifecycle methods detected ({'/'.join(lifecycle)})")
307
312
  suffix = f" — {', '.join(signals)}" if signals else ""