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 CHANGED
@@ -4,4 +4,4 @@ ASK Engine is the product. ``ask`` is the canonical CLI command; ``sourcecode``
4
4
  the legacy compatibility alias and the Python/PyPI package name. See
5
5
  docs/PRODUCT_IDENTITY.md (normative)."""
6
6
 
7
- __version__ = "3.3.0"
7
+ __version__ = "3.5.0"
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",), "shared", False,
129
- "Resolves the conditional bean graph on every run; a warm saves it the Java parse, "
130
- "which is the smaller half of its work.",
131
- "24.5 s 19.3 s"),
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.",
@@ -1,44 +1,151 @@
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.
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
- 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."
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