sourcecode 3.3.0__py3-none-any.whl → 3.5.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.
Potentially problematic release.
This version of sourcecode might be problematic. Click here for more details.
- sourcecode/__init__.py +1 -1
- sourcecode/archetype.py +4 -0
- sourcecode/cache_model.py +5 -4
- sourcecode/caller_metrics.py +146 -39
- sourcecode/caller_reach.py +205 -0
- sourcecode/cli.py +231 -270
- sourcecode/compare.py +6 -2
- sourcecode/degradation.py +373 -0
- sourcecode/explain.py +40 -14
- sourcecode/facts/registry.json +32 -0
- sourcecode/mcp/orchestrator.py +3 -0
- sourcecode/mcp/registry.py +3 -1
- sourcecode/mcp/server.py +4 -2
- sourcecode/migrate_check.py +171 -15
- sourcecode/migration_blast.py +5 -3
- sourcecode/output_budget.py +11 -0
- sourcecode/path_admission.py +153 -0
- sourcecode/posture.py +119 -27
- sourcecode/pr_comment_renderer.py +6 -1
- sourcecode/readiness_timeline.py +16 -1
- sourcecode/repository_ir.py +96 -48
- sourcecode/retrieval/steps.py +9 -1
- sourcecode/ris.py +11 -0
- sourcecode/serializer.py +10 -0
- sourcecode/spring_impact.py +29 -163
- sourcecode/verify_edit.py +190 -7
- {sourcecode-3.3.0.dist-info → sourcecode-3.5.0.dist-info}/METADATA +11 -8
- {sourcecode-3.3.0.dist-info → sourcecode-3.5.0.dist-info}/RECORD +31 -28
- {sourcecode-3.3.0.dist-info → sourcecode-3.5.0.dist-info}/WHEEL +0 -0
- {sourcecode-3.3.0.dist-info → sourcecode-3.5.0.dist-info}/entry_points.txt +0 -0
- {sourcecode-3.3.0.dist-info → sourcecode-3.5.0.dist-info}/licenses/LICENSE +0 -0
sourcecode/__init__.py
CHANGED
sourcecode/archetype.py
CHANGED
|
@@ -30,6 +30,7 @@ from dataclasses import dataclass, field
|
|
|
30
30
|
from pathlib import Path
|
|
31
31
|
from typing import Any, Optional
|
|
32
32
|
|
|
33
|
+
from sourcecode.degradation import analysis_limiter as _analysis_limiter
|
|
33
34
|
from sourcecode.graph_evidence import GraphEvidence, GraphEvidenceProvider, GraphEvidenceResult
|
|
34
35
|
from sourcecode.schema import SourceMap
|
|
35
36
|
from sourcecode.tree_utils import flatten_file_tree
|
|
@@ -157,6 +158,9 @@ class ArchetypeAnalysis:
|
|
|
157
158
|
},
|
|
158
159
|
"signals_used": self.signals_used,
|
|
159
160
|
"signals_missing": self.signals_missing,
|
|
161
|
+
# I-8: the names alone say nothing about what the classification lost.
|
|
162
|
+
# Same authority the review-pr limiter uses, so both read alike.
|
|
163
|
+
"analysis_limiter": _analysis_limiter(self.signals_missing),
|
|
160
164
|
"generated_from": self.generated_from,
|
|
161
165
|
"graph_metrics": {k: round(v, 4) for k, v in self.graph_metrics.items()},
|
|
162
166
|
}
|
sourcecode/cache_model.py
CHANGED
|
@@ -125,10 +125,11 @@ COMMANDS: tuple[CommandCache, ...] = (
|
|
|
125
125
|
"`--env-map`, `--depth N` and `--exclude` change the *analysis*, so they miss the "
|
|
126
126
|
"warmed core and rescan — this is the 171 s the field measured after a 103 s warm.",
|
|
127
127
|
"--compact 17.7 s → 0.3 s; --agent --full --env-map --depth 20 34.7 s → 33.9 s (no gain)"),
|
|
128
|
-
CommandCache("posture", ("parse"
|
|
129
|
-
"Resolves the conditional bean graph on every run
|
|
130
|
-
"
|
|
131
|
-
"
|
|
128
|
+
CommandCache("posture", ("cir", "parse"), "shared", False,
|
|
129
|
+
"Resolves the conditional bean graph on every run, over the shared CIR a warm "
|
|
130
|
+
"builds — the parse it used to repeat for itself. `--diff` compares two profile "
|
|
131
|
+
"sets over that one IR, so the second side costs the resolution only.",
|
|
132
|
+
"10.1 s → 1.6 s"),
|
|
132
133
|
CommandCache("endpoints", ("ris", "parse"), "none", False,
|
|
133
134
|
"Recomputes the endpoint surface on every run and refreshes the RIS endpoint index. "
|
|
134
135
|
"Measured: a warm buys it nothing.",
|
sourcecode/caller_metrics.py
CHANGED
|
@@ -1,44 +1,151 @@
|
|
|
1
|
-
"""
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
1
|
+
"""The authority for "how many things reference this class" (ADR-0008, R11-R14).
|
|
2
|
+
|
|
3
|
+
Several commands publish such a number. They are not the same number, and the
|
|
4
|
+
gap is not a rounding margin: measured on `org.broadleafcommerce.common.money.Money`
|
|
5
|
+
(BroadleafCommerce, one version, one machine) the product published **four**
|
|
6
|
+
figures — 1 868, 673, 418 and 151 — under names that all read as fan-in. Field
|
|
7
|
+
evaluation #5 asked the only question that matters about that: *"¿el blast radius
|
|
8
|
+
son 484 o 611 clases?"*
|
|
9
|
+
|
|
10
|
+
The previous version of this module promised a relationship in prose:
|
|
11
|
+
`modernize.in_degree >= explain.incoming_callers ~= impact.stats.direct_caller_count`,
|
|
12
|
+
with `in_degree` *"always the largest of the three"* and the other two differing
|
|
13
|
+
*"by a small margin"*. Measurement falsified both halves — `impact-chain` was not
|
|
14
|
+
in the relationship at all and was 2,8× the figure declared always-largest, and
|
|
15
|
+
the *"small margin"* was 177 %. A published relationship is a contract, so this
|
|
16
|
+
module no longer publishes prose: it publishes a **registry of figures**, each
|
|
17
|
+
with its unit and its edge population, and the note is *generated* from it. What
|
|
18
|
+
the battery can assert is what the registry says, which is why it cannot drift
|
|
19
|
+
again.
|
|
20
|
+
|
|
21
|
+
Two of the four figures were also wrong about their own unit, and that is the
|
|
22
|
+
defect underneath the divergence:
|
|
23
|
+
|
|
24
|
+
- `impact.stats.direct_caller_count` counted caller **symbols** — 418, of which
|
|
25
|
+
346 were method-level — while its own note called them distinct classes. The
|
|
26
|
+
same class was counted once per method that touches the target. As classes:
|
|
27
|
+
**134**.
|
|
28
|
+
- `explain.incoming_callers` deduplicated by **simple name**, collapsing 186
|
|
29
|
+
distinct classes into 151 and silently dropping 35 of them (the identity rule
|
|
30
|
+
that made `by_controller` keys FQNs applies here too).
|
|
31
|
+
|
|
32
|
+
With both fixed, the residual difference is a *declared* one: `impact` excludes
|
|
33
|
+
`imports` edges from a blast radius (an import is not a runtime dependency,
|
|
34
|
+
FP-001) and `explain` includes them, so `explain` counts a superset. That is a
|
|
35
|
+
fact about populations, stated per figure, not a promise about magnitudes.
|
|
27
36
|
"""
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
from typing import Iterable
|
|
40
|
+
|
|
41
|
+
from sourcecode.fqn_utils import normalize_owner_fqn
|
|
42
|
+
|
|
43
|
+
#: Unit tags. Two figures are comparable only if both agree.
|
|
44
|
+
UNIT_CLASSES = "distinct_classes"
|
|
45
|
+
UNIT_SYMBOLS = "caller_symbols"
|
|
46
|
+
UNIT_EDGES = "graph_edges"
|
|
47
|
+
|
|
48
|
+
#: Population tags — *which* references were admitted, not how they were counted.
|
|
49
|
+
POP_REFERENCES_WITH_IMPORTS = "references_including_imports"
|
|
50
|
+
POP_REFERENCES_NO_IMPORTS = "references_excluding_imports"
|
|
51
|
+
POP_ALL_EDGES = "all_incoming_edges"
|
|
52
|
+
POP_TRANSITIVE_CHAIN = "transitive_chain"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def caller_classes(symbols: Iterable[str], *, exclude: Iterable[str] = ()) -> set[str]:
|
|
56
|
+
"""The distinct classes behind a set of caller symbols — the fan-in unit.
|
|
57
|
+
|
|
58
|
+
A caller list mixes granularities: `pkg.Utils`, `pkg.Utils#getMoney`,
|
|
59
|
+
`pkg.Utils.field`. Counting it raw answers "how many references" and calls
|
|
60
|
+
it "how many classes". This is the one place that normalisation happens.
|
|
61
|
+
"""
|
|
62
|
+
excluded = {normalize_owner_fqn(e) for e in exclude}
|
|
63
|
+
out: set[str] = set()
|
|
64
|
+
for sym in symbols:
|
|
65
|
+
if not sym:
|
|
66
|
+
continue
|
|
67
|
+
owner = normalize_owner_fqn(sym)
|
|
68
|
+
if owner and owner not in excluded:
|
|
69
|
+
out.add(owner)
|
|
70
|
+
return out
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def caller_class_count(symbols: Iterable[str], *, exclude: Iterable[str] = ()) -> int:
|
|
74
|
+
"""``len(caller_classes(...))`` — the number a fan-in figure may publish."""
|
|
75
|
+
return len(caller_classes(symbols, exclude=exclude))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
#: Every published fan-in figure, with what it counts. A figure that is not here
|
|
79
|
+
#: is not allowed to be emitted (asserted by `tests/test_fan_in_authority.py`).
|
|
80
|
+
FAN_IN_FIGURES: tuple[dict[str, str], ...] = (
|
|
81
|
+
{
|
|
82
|
+
"key": "explain.incoming_callers_count",
|
|
83
|
+
"unit": UNIT_CLASSES,
|
|
84
|
+
"population": POP_REFERENCES_WITH_IMPORTS,
|
|
85
|
+
"means": "distinct classes that reference this class, imports included",
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
"key": "impact.stats.direct_caller_count",
|
|
89
|
+
"unit": UNIT_CLASSES,
|
|
90
|
+
"population": POP_REFERENCES_NO_IMPORTS,
|
|
91
|
+
"means": "distinct classes that call or depend on this class; import-only "
|
|
92
|
+
"references are excluded from a blast radius",
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
"key": "impact.stats.direct_caller_symbol_count",
|
|
96
|
+
"unit": UNIT_SYMBOLS,
|
|
97
|
+
"population": POP_REFERENCES_NO_IMPORTS,
|
|
98
|
+
"means": "the same population counted as reference sites (methods and "
|
|
99
|
+
"fields), which is what the risk score weighs",
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
"key": "impact-chain.metadata.chain_classes_total",
|
|
103
|
+
"unit": UNIT_CLASSES,
|
|
104
|
+
"population": POP_TRANSITIVE_CHAIN,
|
|
105
|
+
"means": "every class in the call chain, direct AND transitive — a reach "
|
|
106
|
+
"figure, not fan-in; unbounded above by any direct-caller count",
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
"key": "impact-chain.metadata.direct_caller_count",
|
|
110
|
+
"unit": UNIT_CLASSES,
|
|
111
|
+
"population": POP_REFERENCES_NO_IMPORTS,
|
|
112
|
+
"means": "distinct classes calling the target at depth 1",
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
"key": "modernize.in_degree",
|
|
116
|
+
"unit": UNIT_EDGES,
|
|
117
|
+
"population": POP_ALL_EDGES,
|
|
118
|
+
"means": "incoming graph edges at symbol level, not deduplicated to "
|
|
119
|
+
"classes — a ranking weight, never a count of classes",
|
|
120
|
+
},
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _render_reconciliation() -> str:
|
|
125
|
+
lines = [
|
|
126
|
+
"Fan-in figures differ across commands because they count different "
|
|
127
|
+
"things. Each figure below states its unit and the references it admits; "
|
|
128
|
+
"two figures are comparable only when both match. No ordering between "
|
|
129
|
+
"units is promised — an earlier version of this note promised one and "
|
|
130
|
+
"measurement falsified it."
|
|
131
|
+
]
|
|
132
|
+
for fig in FAN_IN_FIGURES:
|
|
133
|
+
lines.append(f"{fig['key']} [{fig['unit']}] = {fig['means']}.")
|
|
134
|
+
lines.append(
|
|
135
|
+
"Any list of callers in the payload may be truncated for output size and "
|
|
136
|
+
"is a display sample: read the *_count fields, never len() of the array."
|
|
137
|
+
)
|
|
138
|
+
return " ".join(lines)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
CALLER_METRIC_RECONCILIATION: str = _render_reconciliation()
|
|
28
142
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
"
|
|
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."
|
|
143
|
+
#: Kept for the payload key that was `callers_total` before it was named for what
|
|
144
|
+
#: it counts. Consumers reading the old key get the same number and this note.
|
|
145
|
+
CALLERS_TOTAL_DEPRECATION: str = (
|
|
146
|
+
"metadata.callers_total is the size of the whole call chain (direct + "
|
|
147
|
+
"transitive), not a fan-in count; it is superseded by "
|
|
148
|
+
"metadata.chain_classes_total and metadata.direct_caller_count."
|
|
42
149
|
)
|
|
43
150
|
|
|
44
151
|
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""What a change to a symbol reaches backwards — one traversal, one edge population.
|
|
2
|
+
|
|
3
|
+
Fan-in *counting* has an authority (`caller_metrics`). What it counts did not:
|
|
4
|
+
two traversals walked the reverse graph, and they disagreed about both which
|
|
5
|
+
edges are a dependency and which nodes have any.
|
|
6
|
+
|
|
7
|
+
Measured on `org.broadleafcommerce.common.money.Money` (BroadleafCommerce, one
|
|
8
|
+
version, one machine), the divergence landed on the blast-radius axis as a
|
|
9
|
+
false zero: `impact` published ``endpoints_affected_count: 0`` while
|
|
10
|
+
`impact-chain` returned 21 endpoints across 2 controllers for the same symbol
|
|
11
|
+
at the same depth. `impact` reached 241 classes and **no controller at all**;
|
|
12
|
+
`impact-chain` reached 441 and found them. Two causes, both in the traversal:
|
|
13
|
+
|
|
14
|
+
- **Callers of a class live on its method keys.** ``reverse_graph`` stores the
|
|
15
|
+
callers of ``Foo#doWork`` under ``"Foo#doWork"``, never under ``"Foo"``. The
|
|
16
|
+
older traversal looked up the exact key only, so every time the walk enqueued
|
|
17
|
+
a class-level node — which is what a DI ``injects`` edge normalises to — the
|
|
18
|
+
chain terminated there. That is why the indirect reach was 125 symbols instead
|
|
19
|
+
of 1 442, and why no controller was ever reached: the controller is two DI
|
|
20
|
+
hops past the point where the walk stopped.
|
|
21
|
+
- **The two edge populations were different, while a comment claimed they were
|
|
22
|
+
the same.** The older set skipped ``contained_in`` and ``imports`` and said it
|
|
23
|
+
was *"consistent with spring_impact._SKIP_EDGE_TYPES"*; that set also skips
|
|
24
|
+
``implements`` and ``extends``, because an implementor does not call an
|
|
25
|
+
interface by virtue of implementing it (CH-006: on a 43-implementor interface
|
|
26
|
+
this turned a leaf endpoint into 42 false direct callers). So one traversal
|
|
27
|
+
under-reached while admitting false callers.
|
|
28
|
+
|
|
29
|
+
This module is the single authority for the reach fact. Both `impact` and
|
|
30
|
+
`impact-chain` walk it, so they agree by construction rather than by review.
|
|
31
|
+
Counting the result is still `caller_metrics`' job: reach produces symbols, and
|
|
32
|
+
a symbol is not a class.
|
|
33
|
+
"""
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
from typing import Optional
|
|
37
|
+
|
|
38
|
+
from sourcecode.fqn_utils import normalize_owner_fqn
|
|
39
|
+
|
|
40
|
+
#: Edge types excluded from caller reach — the one definition.
|
|
41
|
+
#:
|
|
42
|
+
#: ``contained_in`` — structural membership (method → enclosing class), not a call.
|
|
43
|
+
#: ``imports`` — a type reference, not a runtime call (FP-001). Including it chains
|
|
44
|
+
#: through DTOs and entities that merely name the type.
|
|
45
|
+
#: ``implements`` / ``extends`` — CH-006: the reverse edge on an interface lists its
|
|
46
|
+
#: implementors, and an implementor does not *call* the interface by implementing
|
|
47
|
+
#: it. The interface→impl expansion that IS wanted flows through the
|
|
48
|
+
#: `ImplementationGraph` indices below, not through these edges.
|
|
49
|
+
SKIP_EDGE_TYPES: frozenset[str] = frozenset(
|
|
50
|
+
{"contained_in", "imports", "implements", "extends"}
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
#: Hub-class guard: above this many unique direct callers, deep BFS is O(n^depth).
|
|
54
|
+
CALLER_CAP = 500
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def class_method_index(
|
|
58
|
+
reverse_graph: dict[str, dict[str, list[str]]],
|
|
59
|
+
) -> dict[str, list[str]]:
|
|
60
|
+
"""``{class FQN: [its method-level reverse-graph keys]}``.
|
|
61
|
+
|
|
62
|
+
The index that makes a class-level node traversable at all. Built once per
|
|
63
|
+
walk; every lookup after that is O(1).
|
|
64
|
+
"""
|
|
65
|
+
index: dict[str, list[str]] = {}
|
|
66
|
+
for key in reverse_graph:
|
|
67
|
+
if "#" in key:
|
|
68
|
+
index.setdefault(key.split("#")[0], []).append(key)
|
|
69
|
+
return index
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def edges_for(
|
|
73
|
+
fqn: str,
|
|
74
|
+
reverse_graph: dict[str, dict[str, list[str]]],
|
|
75
|
+
index: Optional[dict[str, list[str]]] = None,
|
|
76
|
+
impl_graph: object | None = None,
|
|
77
|
+
) -> list[tuple[str, list[str]]]:
|
|
78
|
+
"""Every ``(edge type, callers)`` pair that belongs to *fqn*.
|
|
79
|
+
|
|
80
|
+
For a class-level FQN that includes the entries stored under its method keys
|
|
81
|
+
and, when an `ImplementationGraph` is supplied, the reverse edges of the
|
|
82
|
+
interfaces it implements — callers inject the interface, not the
|
|
83
|
+
implementation, so the DI boundary is crossed here or not at all.
|
|
84
|
+
"""
|
|
85
|
+
edges: list[tuple[str, list[str]]] = list((reverse_graph.get(fqn) or {}).items())
|
|
86
|
+
if "#" in fqn:
|
|
87
|
+
return edges
|
|
88
|
+
idx = class_method_index(reverse_graph) if index is None else index
|
|
89
|
+
for method_key in idx.get(fqn, []):
|
|
90
|
+
edges.extend((reverse_graph.get(method_key) or {}).items())
|
|
91
|
+
if impl_graph is not None:
|
|
92
|
+
for iface in impl_graph.interfaces_of(fqn): # type: ignore[attr-defined]
|
|
93
|
+
edges.extend((reverse_graph.get(iface) or {}).items())
|
|
94
|
+
for method_key in idx.get(iface, []):
|
|
95
|
+
edges.extend((reverse_graph.get(method_key) or {}).items())
|
|
96
|
+
return edges
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def callers_of(
|
|
100
|
+
fqn: str,
|
|
101
|
+
reverse_graph: dict[str, dict[str, list[str]]],
|
|
102
|
+
index: Optional[dict[str, list[str]]] = None,
|
|
103
|
+
impl_graph: object | None = None,
|
|
104
|
+
) -> list[str]:
|
|
105
|
+
"""The callers of *fqn*: reach edges only, injection sites normalised, ordered.
|
|
106
|
+
|
|
107
|
+
An ``injects`` edge names the injection *site* (``pkg.Class.field``,
|
|
108
|
+
``pkg.Class#<init>``), not a caller; the owning class is what depends on the
|
|
109
|
+
target, and it is what the walk has to continue from (CH-002).
|
|
110
|
+
"""
|
|
111
|
+
callers: list[str] = []
|
|
112
|
+
seen: set[str] = set()
|
|
113
|
+
for edge_type, fqn_list in edges_for(fqn, reverse_graph, index, impl_graph):
|
|
114
|
+
if edge_type in SKIP_EDGE_TYPES:
|
|
115
|
+
continue
|
|
116
|
+
for caller in fqn_list:
|
|
117
|
+
normalized = normalize_owner_fqn(caller) if edge_type == "injects" else caller
|
|
118
|
+
if normalized not in seen:
|
|
119
|
+
seen.add(normalized)
|
|
120
|
+
callers.append(normalized)
|
|
121
|
+
return callers
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def bfs_callers(
|
|
125
|
+
seed_fqns: list[str],
|
|
126
|
+
reverse_graph: dict[str, dict[str, list[str]]],
|
|
127
|
+
max_depth: int,
|
|
128
|
+
impl_graph: object | None = None,
|
|
129
|
+
method_scoped: bool = False,
|
|
130
|
+
) -> tuple[list[str], list[str], bool, int]:
|
|
131
|
+
"""Walk the reverse graph from *seed_fqns*.
|
|
132
|
+
|
|
133
|
+
Returns ``(direct, indirect, was_truncated, self_excluded)``. ``direct`` is
|
|
134
|
+
depth 1 — the callers of the seeds themselves; ``indirect`` is depth 2 and
|
|
135
|
+
beyond, up to *max_depth*. ``was_truncated`` says the hub-class guard capped
|
|
136
|
+
the walk, which makes the result a floor.
|
|
137
|
+
|
|
138
|
+
A class's own members are members, not external callers, so they are dropped
|
|
139
|
+
and counted (BUG #2 — leaving them in inflated a blast radius ~12×). For a
|
|
140
|
+
*method*-scoped query the only true self-reference is the seed method calling
|
|
141
|
+
itself: a different method of the same class is a legitimate caller, and
|
|
142
|
+
applying the class rule there produced a false "0 callers / isolated" for
|
|
143
|
+
every private helper (P0-IMPACT-01).
|
|
144
|
+
"""
|
|
145
|
+
visited: set[str] = set(seed_fqns)
|
|
146
|
+
direct: list[str] = []
|
|
147
|
+
indirect: list[str] = []
|
|
148
|
+
was_truncated = False
|
|
149
|
+
|
|
150
|
+
seed_classes: set[str] = {normalize_owner_fqn(s) for s in seed_fqns}
|
|
151
|
+
seed_set: set[str] = set(seed_fqns)
|
|
152
|
+
self_excluded = 0
|
|
153
|
+
|
|
154
|
+
def _is_self_reference(caller: str) -> bool:
|
|
155
|
+
if method_scoped:
|
|
156
|
+
return caller in seed_set # only genuine recursion of a seed method
|
|
157
|
+
return normalize_owner_fqn(caller) in seed_classes
|
|
158
|
+
|
|
159
|
+
index = class_method_index(reverse_graph)
|
|
160
|
+
|
|
161
|
+
def _edges(fqn: str) -> list[tuple[str, list[str]]]:
|
|
162
|
+
return edges_for(fqn, reverse_graph, index, impl_graph)
|
|
163
|
+
|
|
164
|
+
# Hub-class guard on the UNIQUE direct caller set, not the raw sum per seed:
|
|
165
|
+
# interface expansion adds many method-level seeds that share callers, and
|
|
166
|
+
# counting those twice tripped the guard on symbols that never needed it.
|
|
167
|
+
unique_direct: set[str] = set()
|
|
168
|
+
for seed in seed_fqns:
|
|
169
|
+
for etype, fqn_list in _edges(seed):
|
|
170
|
+
if etype not in SKIP_EDGE_TYPES:
|
|
171
|
+
unique_direct.update(c for c in fqn_list if not _is_self_reference(c))
|
|
172
|
+
|
|
173
|
+
effective_depth = 1 if len(unique_direct) > CALLER_CAP else max_depth
|
|
174
|
+
if effective_depth < max_depth:
|
|
175
|
+
was_truncated = True
|
|
176
|
+
|
|
177
|
+
queue: list[tuple[str, int]] = [(s, 0) for s in seed_fqns]
|
|
178
|
+
|
|
179
|
+
def _add_caller(caller: str, depth: int) -> None:
|
|
180
|
+
nonlocal self_excluded
|
|
181
|
+
if caller in visited:
|
|
182
|
+
return
|
|
183
|
+
if _is_self_reference(caller):
|
|
184
|
+
visited.add(caller)
|
|
185
|
+
self_excluded += 1
|
|
186
|
+
return
|
|
187
|
+
visited.add(caller)
|
|
188
|
+
(direct if depth == 0 else indirect).append(caller)
|
|
189
|
+
if depth + 1 < effective_depth:
|
|
190
|
+
queue.append((caller, depth + 1))
|
|
191
|
+
|
|
192
|
+
while queue:
|
|
193
|
+
fqn, depth = queue.pop(0)
|
|
194
|
+
if depth >= effective_depth:
|
|
195
|
+
continue
|
|
196
|
+
for etype, fqn_list in _edges(fqn):
|
|
197
|
+
if etype in SKIP_EDGE_TYPES:
|
|
198
|
+
continue
|
|
199
|
+
for caller in fqn_list:
|
|
200
|
+
if etype == "injects":
|
|
201
|
+
_add_caller(normalize_owner_fqn(caller), depth)
|
|
202
|
+
else:
|
|
203
|
+
_add_caller(caller, depth)
|
|
204
|
+
|
|
205
|
+
return direct, indirect, was_truncated, self_excluded
|