sourcecode 3.2.0__py3-none-any.whl → 3.2.2__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/cache.py +104 -0
- sourcecode/cache_model.py +295 -0
- sourcecode/chain_rules.py +49 -8
- sourcecode/cli.py +419 -205
- sourcecode/confidence_analyzer.py +10 -6
- sourcecode/context_cache.py +7 -27
- sourcecode/deployment_prefix.py +85 -0
- sourcecode/facts/__init__.py +71 -0
- sourcecode/facts/registry.json +158 -0
- sourcecode/license.py +1 -1
- sourcecode/mcp/registry.py +1 -1
- sourcecode/mcp/server.py +3 -3
- sourcecode/metrics_analyzer.py +8 -5
- sourcecode/posture.py +78 -6
- sourcecode/prepare_context.py +51 -26
- sourcecode/reference_facts.py +307 -0
- sourcecode/ris.py +27 -1
- sourcecode/serializer.py +36 -9
- sourcecode/spring_profiles.py +93 -13
- sourcecode/telemetry/__init__.py +4 -3
- sourcecode/telemetry/config.py +31 -23
- sourcecode/telemetry/consent.py +20 -16
- sourcecode/test_sources.py +178 -0
- {sourcecode-3.2.0.dist-info → sourcecode-3.2.2.dist-info}/METADATA +128 -14
- {sourcecode-3.2.0.dist-info → sourcecode-3.2.2.dist-info}/RECORD +29 -24
- {sourcecode-3.2.0.dist-info → sourcecode-3.2.2.dist-info}/WHEEL +0 -0
- {sourcecode-3.2.0.dist-info → sourcecode-3.2.2.dist-info}/entry_points.txt +0 -0
- {sourcecode-3.2.0.dist-info → sourcecode-3.2.2.dist-info}/licenses/LICENSE +0 -0
sourcecode/prepare_context.py
CHANGED
|
@@ -1090,20 +1090,20 @@ class TaskContextBuilder:
|
|
|
1090
1090
|
def _try_ris_fast_path(self, task_name: str, spec: "TaskSpec") -> Optional[TaskOutput]:
|
|
1091
1091
|
"""Return TaskOutput from a warm RIS without running the full scan.
|
|
1092
1092
|
|
|
1093
|
-
Only activated for onboard/explain when
|
|
1093
|
+
Only activated for onboard/explain when the stored RIS describes the tree
|
|
1094
|
+
on disk — the same freshness authority every cache layer keys on, not the
|
|
1095
|
+
committed HEAD (which served this fast path through uncommitted edits).
|
|
1094
1096
|
Falls through (returns None) on any error or cache miss.
|
|
1095
1097
|
"""
|
|
1096
1098
|
try:
|
|
1097
|
-
import subprocess as _sp
|
|
1098
1099
|
from sourcecode.ris import load_ris as _lris
|
|
1100
|
+
from sourcecode.ris import _tree_signature as _ris_sig
|
|
1099
1101
|
_ris = _lris(self.root)
|
|
1100
1102
|
if _ris is None or not _ris.compact_summary:
|
|
1101
1103
|
return None
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
)
|
|
1106
|
-
if _r.returncode != 0 or _r.stdout.strip() != _ris.git_head:
|
|
1104
|
+
_stored_sig = (_ris.metadata or {}).get("tree_signature") or ""
|
|
1105
|
+
_current_sig = _ris_sig(self.root)
|
|
1106
|
+
if not _stored_sig or not _current_sig or _stored_sig != _current_sig:
|
|
1107
1107
|
return None
|
|
1108
1108
|
|
|
1109
1109
|
compact = _ris.compact_summary
|
|
@@ -1905,17 +1905,46 @@ class TaskContextBuilder:
|
|
|
1905
1905
|
for p in test_set
|
|
1906
1906
|
} | {Path(p).stem for p in test_set}
|
|
1907
1907
|
_untested_changed = [f for f in _changed_src if Path(f).stem not in _test_stems]
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1908
|
+
# A diff that changes no source file has not been assessed for test
|
|
1909
|
+
# coverage — it is not low risk. Reported as `low`, a documentation-only
|
|
1910
|
+
# diff in a repository with no tests at all read as "covered", which is
|
|
1911
|
+
# the traffic light inverted where a reviewer relies on it (ADR-0008 R9:
|
|
1912
|
+
# `unknown` where the honest answer is unknown, never a manufactured
|
|
1913
|
+
# best case).
|
|
1914
|
+
if not _changed_src:
|
|
1915
|
+
_test_risk_level = "unknown"
|
|
1916
|
+
_test_risk_basis = (
|
|
1917
|
+
"no source files changed in this diff — test coverage risk "
|
|
1918
|
+
"not assessed"
|
|
1919
|
+
)
|
|
1920
|
+
else:
|
|
1921
|
+
_test_risk_level = (
|
|
1922
|
+
"high" if len(_untested_changed) > 3
|
|
1923
|
+
else "medium" if _untested_changed
|
|
1924
|
+
else "low"
|
|
1925
|
+
)
|
|
1926
|
+
_test_risk_basis = (
|
|
1927
|
+
f"{len(_untested_changed)} of {len(_changed_src)} changed source "
|
|
1928
|
+
f"files have no matching test file by stem"
|
|
1929
|
+
)
|
|
1913
1930
|
_pr_test_coverage_risk = {
|
|
1914
1931
|
"changed_files_without_tests": _untested_changed[:10],
|
|
1915
1932
|
"risk_level": _test_risk_level,
|
|
1916
1933
|
"epistemic_level": "INFERRED (LOW CONFIDENCE)",
|
|
1917
|
-
"basis":
|
|
1934
|
+
"basis": _test_risk_basis,
|
|
1935
|
+
"changed_source_files": len(_changed_src),
|
|
1918
1936
|
}
|
|
1937
|
+
if not test_set:
|
|
1938
|
+
# The repository declares no test sources at all: "has a matching
|
|
1939
|
+
# test file by stem" cannot mean anything here, and a `low` would
|
|
1940
|
+
# be read as coverage.
|
|
1941
|
+
_pr_test_coverage_risk["repository_has_test_sources"] = False
|
|
1942
|
+
if _test_risk_level == "low":
|
|
1943
|
+
_pr_test_coverage_risk["risk_level"] = "unknown"
|
|
1944
|
+
_pr_test_coverage_risk["basis"] = (
|
|
1945
|
+
"repository declares no test sources — coverage of the "
|
|
1946
|
+
"changed files cannot be assessed"
|
|
1947
|
+
)
|
|
1919
1948
|
|
|
1920
1949
|
# Pre-classify changed files once — reused for hotspots, order, and runtime/build split
|
|
1921
1950
|
_pr_changed_cls: dict[str, dict] = {
|
|
@@ -3331,19 +3360,15 @@ class TaskContextBuilder:
|
|
|
3331
3360
|
return [f for _, _, f in scored[:15]]
|
|
3332
3361
|
|
|
3333
3362
|
def _is_test(self, path: str) -> bool:
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
|
|
3339
|
-
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
or (name.endswith("test.java") and name != "test.java")
|
|
3344
|
-
or name.endswith("tests.java")
|
|
3345
|
-
or (name.startswith("test") and name.endswith(".java") and len(name) > 9)
|
|
3346
|
-
)
|
|
3363
|
+
"""Delegates to the one authority (ADR-0008 R1).
|
|
3364
|
+
|
|
3365
|
+
This used to match `/test/` anywhere in the path, so production code in
|
|
3366
|
+
a package named `test` under `src/main` counted as a test — inflating
|
|
3367
|
+
coverage in the one direction a reviewer must not be misled about.
|
|
3368
|
+
"""
|
|
3369
|
+
from sourcecode.test_sources import is_test_path
|
|
3370
|
+
|
|
3371
|
+
return is_test_path(path)
|
|
3347
3372
|
|
|
3348
3373
|
def _is_source(self, path: str) -> bool:
|
|
3349
3374
|
return Path(path).suffix.lower() in _SOURCE_EXTENSIONS
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""reference_facts.py — one authority for "is this type referenced anywhere?".
|
|
2
|
+
|
|
3
|
+
A static call-graph answers "who calls this class" for the calls it can see. It
|
|
4
|
+
cannot see a class a framework instantiates by reflection, wires by name from
|
|
5
|
+
XML, discovers through the service-loader, or dispatches into because of an
|
|
6
|
+
annotation. Those are not edge cases in a Spring/Jakarta repository: they are how
|
|
7
|
+
the entry points work.
|
|
8
|
+
|
|
9
|
+
So this question has **three** answers, not two:
|
|
10
|
+
|
|
11
|
+
* ``referenced`` — the graph carries an incoming edge, or
|
|
12
|
+
another source file names the type
|
|
13
|
+
* ``unknown_framework_dispatch`` — no static caller, but the type carries a
|
|
14
|
+
dispatch signal (published annotation,
|
|
15
|
+
entry-point signature, or its name wired
|
|
16
|
+
from a configuration file). Whether it runs
|
|
17
|
+
is not decidable here
|
|
18
|
+
* ``no_static_callers`` — no incoming edge and no dispatch signal
|
|
19
|
+
found. Still **not** "dead code": it is the
|
|
20
|
+
absence of evidence, stated as such
|
|
21
|
+
|
|
22
|
+
The previous shape of this fact had two consequences the field caught. It counted
|
|
23
|
+
only types with **no edges in either direction**, while calling the result "zero
|
|
24
|
+
static callers" — a class that calls something but is called by nothing was never
|
|
25
|
+
examined. And when nothing survived that narrower filter it published ``0``, which
|
|
26
|
+
reads as "there is no unreferenced code here" rather than "nothing was found by a
|
|
27
|
+
check that cannot see framework dispatch" (ADR-0008 R9, R5-R8: the count comes
|
|
28
|
+
from the measurement, never from the length of a truncated list).
|
|
29
|
+
|
|
30
|
+
VAI: annotation and configuration names appear only as published open-standard
|
|
31
|
+
DATA (Jakarta / Spring / Java SE vocabulary), the same class of table as the
|
|
32
|
+
security-posture matchers. Nothing branches on a project, vendor or product name,
|
|
33
|
+
and a name found in configuration is recorded as evidence of a possible wire —
|
|
34
|
+
never as proof that a class is alive.
|
|
35
|
+
"""
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import os
|
|
39
|
+
import re
|
|
40
|
+
from dataclasses import dataclass, field
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
|
|
43
|
+
#: Statuses, in the order a reader cares about them. `unknown_framework_dispatch`
|
|
44
|
+
#: sits between the two decided answers because that is what it means.
|
|
45
|
+
REFERENCED = "referenced"
|
|
46
|
+
UNKNOWN_DISPATCH = "unknown_framework_dispatch"
|
|
47
|
+
NO_STATIC_CALLERS = "no_static_callers"
|
|
48
|
+
STATUSES: tuple[str, ...] = (REFERENCED, UNKNOWN_DISPATCH, NO_STATIC_CALLERS)
|
|
49
|
+
|
|
50
|
+
#: Published signatures and annotations that mean "a framework calls this, not
|
|
51
|
+
#: your code". DATA: a table of open-standard names, extended without changing
|
|
52
|
+
#: any logic. A hit makes the answer *unknown*, never "alive".
|
|
53
|
+
_DYNAMIC_ENTRY_SIGNATURE_RE = re.compile(
|
|
54
|
+
r"\(\s*DispatchContext\b" # service-engine service
|
|
55
|
+
r"|HttpServletRequest\s+\w+\s*,\s*HttpServletResponse" # event / servlet handler
|
|
56
|
+
r"|\bpublic\s+static\s+void\s+main\s*\(" # JVM entry point
|
|
57
|
+
r"|@(?:Scheduled|PostConstruct|PreDestroy|EventListener|Bean|"
|
|
58
|
+
r"RequestMapping|GetMapping|PostMapping|PutMapping|DeleteMapping|PatchMapping|"
|
|
59
|
+
r"Path|GET|POST|Provider|ApplicationScoped|Singleton|"
|
|
60
|
+
# Stereotypes and persistence: the container instantiates these itself, so a
|
|
61
|
+
# static call-graph is the wrong instrument to declare them unused.
|
|
62
|
+
r"Component|Service|Repository|Controller|RestController|ControllerAdvice|"
|
|
63
|
+
r"Configuration|SpringBootApplication|Aspect|Entity|Embeddable|"
|
|
64
|
+
r"MappedSuperclass|Converter|WebServlet|WebFilter|WebListener)\b"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
#: Configuration formats where a framework wires a class by name.
|
|
68
|
+
_CONFIG_REF_EXTS: frozenset = frozenset({".xml", ".properties", ".yml", ".yaml", ".groovy"})
|
|
69
|
+
_SKIP_DIRS: frozenset = frozenset({".git", "build", "out", "target", "node_modules", ".gradle"})
|
|
70
|
+
_SCAN_MAX_FILES: int = 12000
|
|
71
|
+
_SCAN_MAX_BYTES: int = 256 * 1024
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class TypeReference:
|
|
76
|
+
"""One type's reference status, with the evidence that produced it."""
|
|
77
|
+
|
|
78
|
+
fqn: str
|
|
79
|
+
status: str
|
|
80
|
+
basis: str
|
|
81
|
+
type: str = ""
|
|
82
|
+
role: str = ""
|
|
83
|
+
|
|
84
|
+
def to_dict(self) -> dict:
|
|
85
|
+
return {
|
|
86
|
+
"fqn": self.fqn,
|
|
87
|
+
"type": self.type,
|
|
88
|
+
"role": self.role,
|
|
89
|
+
"status": self.status,
|
|
90
|
+
"basis": self.basis,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True)
|
|
95
|
+
class ReferenceFacts:
|
|
96
|
+
"""Every type examined, partitioned into the three answers.
|
|
97
|
+
|
|
98
|
+
`by_status` sums to `classes_examined` — the partition is the point. Counts
|
|
99
|
+
are computed here, so no consumer can derive one from a list it truncated
|
|
100
|
+
for display.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
classes_examined: int = 0
|
|
104
|
+
by_status: "dict[str, int]" = field(default_factory=dict)
|
|
105
|
+
entries: "tuple[TypeReference, ...]" = ()
|
|
106
|
+
|
|
107
|
+
def of_status(self, status: str) -> "list[TypeReference]":
|
|
108
|
+
return [e for e in self.entries if e.status == status]
|
|
109
|
+
|
|
110
|
+
def count(self, status: str) -> int:
|
|
111
|
+
return int(self.by_status.get(status, 0))
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def statement(self) -> str:
|
|
115
|
+
"""What the numbers mean, in the words that keep them honest."""
|
|
116
|
+
unknown = self.count(UNKNOWN_DISPATCH)
|
|
117
|
+
none_found = self.count(NO_STATIC_CALLERS)
|
|
118
|
+
if not self.classes_examined:
|
|
119
|
+
return "no class or interface declarations were examined"
|
|
120
|
+
if none_found == 0 and unknown:
|
|
121
|
+
return (
|
|
122
|
+
f"no type is without a caller AND without a dispatch signal; "
|
|
123
|
+
f"{unknown} type(s) have no static caller but carry one, so whether "
|
|
124
|
+
"they are reachable is not decidable from source"
|
|
125
|
+
)
|
|
126
|
+
return (
|
|
127
|
+
f"{none_found} type(s) have no static caller and no dispatch signal "
|
|
128
|
+
f"found; {unknown} more have no static caller but carry one. Neither "
|
|
129
|
+
"group is confirmed dead code"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
def to_dict(self) -> dict:
|
|
133
|
+
return {
|
|
134
|
+
"classes_examined": self.classes_examined,
|
|
135
|
+
**{s: self.count(s) for s in STATUSES},
|
|
136
|
+
"statement": self.statement,
|
|
137
|
+
"note": (
|
|
138
|
+
"A partition of every class/interface declaration: the three counts "
|
|
139
|
+
"sum to classes_examined. `no_static_callers` means no incoming edge "
|
|
140
|
+
"and no dispatch signal was found — absence of evidence, not evidence "
|
|
141
|
+
"of dead code. `unknown_framework_dispatch` means a framework may "
|
|
142
|
+
"invoke it (annotation, entry-point signature, or a name wired from "
|
|
143
|
+
"configuration) and a static call-graph cannot decide."
|
|
144
|
+
),
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _read(path: str, limit: int = _SCAN_MAX_BYTES) -> str:
|
|
149
|
+
try:
|
|
150
|
+
with open(path, "r", encoding="utf-8", errors="replace") as handle:
|
|
151
|
+
return handle.read(limit)
|
|
152
|
+
except OSError:
|
|
153
|
+
return ""
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
#: Identifiers, and `Outer.Nested` qualified references. Two fixed patterns whose
|
|
157
|
+
#: hits are intersected with the candidate names — an alternation over the
|
|
158
|
+
#: candidates instead is 7× slower on a 6 000-type repository and answers the same.
|
|
159
|
+
_IDENTIFIER_RE = re.compile(r"[A-Za-z_$][A-Za-z0-9_$]*")
|
|
160
|
+
_QUALIFIED_TYPE_RE = re.compile(r"\b[A-Z][A-Za-z0-9_$]*\.[A-Z][A-Za-z0-9_$]*")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _scan_repository(
|
|
164
|
+
root: Path, simple_names: "set[str]", nested: "dict[str, tuple[str, str]]"
|
|
165
|
+
) -> "tuple[dict[str, str], dict[str, str]]":
|
|
166
|
+
"""One walk, two questions — `(config-wired names, nested types other code names)`.
|
|
167
|
+
|
|
168
|
+
Both scans need the whole tree, and walking it twice doubled the cost of the
|
|
169
|
+
command's slowest step for no answer that a single pass cannot give.
|
|
170
|
+
|
|
171
|
+
Names are matched whole-word, never as substrings: `Order` appearing inside
|
|
172
|
+
`OrderItemImpl` is not a wire, and treating it as one silently marks a
|
|
173
|
+
live-looking class dispatched. A type naming itself proves nothing, so its own
|
|
174
|
+
source file is skipped.
|
|
175
|
+
"""
|
|
176
|
+
config_hits: dict[str, str] = {}
|
|
177
|
+
nested_hits: dict[str, str] = {}
|
|
178
|
+
pending = set(simple_names)
|
|
179
|
+
by_qualified: dict[str, list[str]] = {}
|
|
180
|
+
for fqn, (qualified, _own) in nested.items():
|
|
181
|
+
by_qualified.setdefault(qualified, []).append(fqn)
|
|
182
|
+
|
|
183
|
+
scan_config = bool(pending)
|
|
184
|
+
scan_java = bool(by_qualified)
|
|
185
|
+
if not scan_config and not scan_java:
|
|
186
|
+
return config_hits, nested_hits
|
|
187
|
+
|
|
188
|
+
scanned = 0
|
|
189
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
190
|
+
dirnames[:] = [d for d in dirnames if d not in _SKIP_DIRS]
|
|
191
|
+
for filename in filenames:
|
|
192
|
+
is_java = filename.endswith(".java")
|
|
193
|
+
is_config = os.path.splitext(filename)[1].lower() in _CONFIG_REF_EXTS
|
|
194
|
+
if not ((is_java and scan_java) or (is_config and scan_config)):
|
|
195
|
+
continue
|
|
196
|
+
if scanned >= _SCAN_MAX_FILES:
|
|
197
|
+
break
|
|
198
|
+
full = os.path.join(dirpath, filename)
|
|
199
|
+
text = _read(full)
|
|
200
|
+
scanned += 1
|
|
201
|
+
if not text:
|
|
202
|
+
continue
|
|
203
|
+
rel = os.path.relpath(full, root).replace("\\", "/")
|
|
204
|
+
if is_config and scan_config:
|
|
205
|
+
for hit in set(_IDENTIFIER_RE.findall(text)) & pending:
|
|
206
|
+
pending.discard(hit)
|
|
207
|
+
config_hits[hit] = rel
|
|
208
|
+
scan_config = bool(pending)
|
|
209
|
+
if is_java and scan_java:
|
|
210
|
+
for hit in set(_QUALIFIED_TYPE_RE.findall(text)) & set(by_qualified):
|
|
211
|
+
for fqn in by_qualified[hit]:
|
|
212
|
+
if fqn not in nested_hits and nested[fqn][1] != rel:
|
|
213
|
+
nested_hits[fqn] = rel
|
|
214
|
+
scan_java = len(nested_hits) < len(nested)
|
|
215
|
+
if not scan_config and not scan_java:
|
|
216
|
+
return config_hits, nested_hits
|
|
217
|
+
if scanned >= _SCAN_MAX_FILES:
|
|
218
|
+
break
|
|
219
|
+
return config_hits, nested_hits
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def analyze_type_references(nodes: "list[dict]", root: Path) -> ReferenceFacts:
|
|
223
|
+
"""The reference status of every class/interface node, and why.
|
|
224
|
+
|
|
225
|
+
`nodes` is the graph's node list; only class and interface declarations are
|
|
226
|
+
examined, because a method with no callers is a different question.
|
|
227
|
+
"""
|
|
228
|
+
root = Path(root)
|
|
229
|
+
types = [n for n in nodes or [] if n.get("type") in ("class", "interface") and n.get("fqn")]
|
|
230
|
+
if not types:
|
|
231
|
+
return ReferenceFacts(0, {s: 0 for s in STATUSES}, ())
|
|
232
|
+
|
|
233
|
+
entries: list[TypeReference] = []
|
|
234
|
+
candidates: list[dict] = []
|
|
235
|
+
for node in types:
|
|
236
|
+
if int(node.get("in_degree") or 0) > 0:
|
|
237
|
+
entries.append(TypeReference(
|
|
238
|
+
fqn=str(node["fqn"]), status=REFERENCED,
|
|
239
|
+
basis="incoming edge in the call-graph",
|
|
240
|
+
type=str(node.get("type") or ""), role=str(node.get("role") or "other"),
|
|
241
|
+
))
|
|
242
|
+
else:
|
|
243
|
+
candidates.append(node)
|
|
244
|
+
|
|
245
|
+
# 1. A dispatch signal in the type's own source.
|
|
246
|
+
dispatch_basis: dict[str, str] = {}
|
|
247
|
+
for node in candidates:
|
|
248
|
+
source = node.get("source_file")
|
|
249
|
+
if not source:
|
|
250
|
+
continue
|
|
251
|
+
text = _read(str(root / str(source)))
|
|
252
|
+
if text and _DYNAMIC_ENTRY_SIGNATURE_RE.search(text):
|
|
253
|
+
dispatch_basis[str(node["fqn"])] = (
|
|
254
|
+
f"a framework-dispatch annotation or entry-point signature in {source}"
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
# 2. The type's name wired from a configuration file, and 3. a nested type
|
|
258
|
+
# another source names as `Outer.Nested` — both answered in one walk.
|
|
259
|
+
by_simple: dict[str, list[dict]] = {}
|
|
260
|
+
nested_candidates: dict[str, tuple[str, str]] = {}
|
|
261
|
+
for node in candidates:
|
|
262
|
+
fqn = str(node["fqn"])
|
|
263
|
+
if fqn in dispatch_basis:
|
|
264
|
+
continue
|
|
265
|
+
simple = fqn.rsplit(".", 1)[-1]
|
|
266
|
+
if simple:
|
|
267
|
+
by_simple.setdefault(simple, []).append(node)
|
|
268
|
+
parts = fqn.split(".")
|
|
269
|
+
if len(parts) >= 2 and parts[-2][:1].isupper():
|
|
270
|
+
nested_candidates[fqn] = (
|
|
271
|
+
f"{parts[-2]}.{parts[-1]}",
|
|
272
|
+
str(node.get("source_file") or "").replace("\\", "/"),
|
|
273
|
+
)
|
|
274
|
+
config_hits, nested_hits = _scan_repository(root, set(by_simple), nested_candidates)
|
|
275
|
+
for simple, config_file in config_hits.items():
|
|
276
|
+
for node in by_simple.get(simple, []):
|
|
277
|
+
dispatch_basis[str(node["fqn"])] = f"named in {config_file}"
|
|
278
|
+
|
|
279
|
+
for node in candidates:
|
|
280
|
+
fqn = str(node["fqn"])
|
|
281
|
+
common = {
|
|
282
|
+
"fqn": fqn,
|
|
283
|
+
"type": str(node.get("type") or ""),
|
|
284
|
+
"role": str(node.get("role") or "other"),
|
|
285
|
+
}
|
|
286
|
+
if fqn in nested_hits:
|
|
287
|
+
entries.append(TypeReference(
|
|
288
|
+
status=REFERENCED,
|
|
289
|
+
basis=f"named as a qualified nested type in {nested_hits[fqn]}",
|
|
290
|
+
**common,
|
|
291
|
+
))
|
|
292
|
+
elif fqn in dispatch_basis:
|
|
293
|
+
entries.append(TypeReference(
|
|
294
|
+
status=UNKNOWN_DISPATCH, basis=dispatch_basis[fqn], **common,
|
|
295
|
+
))
|
|
296
|
+
else:
|
|
297
|
+
entries.append(TypeReference(
|
|
298
|
+
status=NO_STATIC_CALLERS,
|
|
299
|
+
basis="no incoming edge, and no dispatch signal was found",
|
|
300
|
+
**common,
|
|
301
|
+
))
|
|
302
|
+
|
|
303
|
+
entries.sort(key=lambda e: (STATUSES.index(e.status), e.fqn))
|
|
304
|
+
counts = {status: sum(1 for e in entries if e.status == status) for status in STATUSES}
|
|
305
|
+
return ReferenceFacts(
|
|
306
|
+
classes_examined=len(types), by_status=counts, entries=tuple(entries),
|
|
307
|
+
)
|
sourcecode/ris.py
CHANGED
|
@@ -258,6 +258,7 @@ def _build_from_core(
|
|
|
258
258
|
1.0 if agent_data else (0.5 if compact_data else 0.0)
|
|
259
259
|
),
|
|
260
260
|
"partial": not bool(agent_data),
|
|
261
|
+
"tree_signature": _tree_signature(repo_root),
|
|
261
262
|
},
|
|
262
263
|
)
|
|
263
264
|
|
|
@@ -295,6 +296,7 @@ def maybe_update_ris(repo_root: Path, core_dict: dict, git_head: str) -> None:
|
|
|
295
296
|
"snapshot_source": "existing_snapshot_system",
|
|
296
297
|
"confidence": float(1.0 if agent_data else (0.5 if compact_data else 0.0)),
|
|
297
298
|
"partial": not bool(agent_data),
|
|
299
|
+
"tree_signature": _tree_signature(repo_root),
|
|
298
300
|
},
|
|
299
301
|
)
|
|
300
302
|
save_ris(repo_root, updated)
|
|
@@ -433,6 +435,21 @@ def _current_git_head(repo_root: Path) -> str:
|
|
|
433
435
|
return ""
|
|
434
436
|
|
|
435
437
|
|
|
438
|
+
def _tree_signature(repo_root: Path) -> str:
|
|
439
|
+
"""Tree state this snapshot describes — the one freshness authority.
|
|
440
|
+
|
|
441
|
+
Stored in ``metadata["tree_signature"]`` at write time so staleness compares
|
|
442
|
+
the tree the RIS was built from against the tree on disk now. HEAD alone
|
|
443
|
+
called a snapshot fresh while uncommitted edits made it describe nothing.
|
|
444
|
+
"""
|
|
445
|
+
try:
|
|
446
|
+
from sourcecode.cache import worktree_signature
|
|
447
|
+
|
|
448
|
+
return worktree_signature(repo_root)
|
|
449
|
+
except Exception:
|
|
450
|
+
return ""
|
|
451
|
+
|
|
452
|
+
|
|
436
453
|
def _has_uncommitted_changes(repo_root: Path) -> bool:
|
|
437
454
|
"""Return True if working tree has staged or unstaged changes to tracked files.
|
|
438
455
|
|
|
@@ -465,8 +482,17 @@ def get_cold_start_context(repo_root: Path) -> dict:
|
|
|
465
482
|
return {"status": "no_ris"}
|
|
466
483
|
|
|
467
484
|
current_head = _current_git_head(repo_root)
|
|
468
|
-
stale = bool(current_head and ris.git_head and current_head != ris.git_head)
|
|
469
485
|
uncommitted = _has_uncommitted_changes(repo_root)
|
|
486
|
+
# Staleness is about the tree the snapshot describes, not the commit it was
|
|
487
|
+
# taken at: with HEAD alone a snapshot stayed `stale: false` through every
|
|
488
|
+
# uncommitted edit. Snapshots written before the signature existed fall back
|
|
489
|
+
# to the pair of facts that were available then.
|
|
490
|
+
_stored_sig = (ris.metadata or {}).get("tree_signature") or ""
|
|
491
|
+
_current_sig = _tree_signature(repo_root)
|
|
492
|
+
if _stored_sig and _current_sig:
|
|
493
|
+
stale = _stored_sig != _current_sig
|
|
494
|
+
else:
|
|
495
|
+
stale = bool(current_head and ris.git_head and current_head != ris.git_head) or uncommitted
|
|
470
496
|
|
|
471
497
|
endpoints = ris.api_surface.get("endpoints", [])
|
|
472
498
|
_is_java = (
|
sourcecode/serializer.py
CHANGED
|
@@ -564,19 +564,30 @@ def _spring_profiles_context(sm: "SourceMap") -> "Optional[dict[str, Any]]":
|
|
|
564
564
|
# profile is routinely the security filter chain.
|
|
565
565
|
_declared: list[str] = []
|
|
566
566
|
_conditional: dict[str, list[str]] = {}
|
|
567
|
+
_conditional_beans: list[dict[str, Any]] = []
|
|
567
568
|
_root = getattr(sm.metadata, "analyzed_path", None)
|
|
568
569
|
if _root:
|
|
569
570
|
try:
|
|
570
571
|
from sourcecode.spring_profiles import (
|
|
571
|
-
|
|
572
|
+
conditional_beans_from_sources_detailed,
|
|
572
573
|
profiles_from_config,
|
|
573
574
|
)
|
|
574
575
|
|
|
575
|
-
# One source pass:
|
|
576
|
-
#
|
|
577
|
-
|
|
576
|
+
# One source pass, one fact: every rendering below is a projection
|
|
577
|
+
# of this list, so the map cannot disagree with the expressions
|
|
578
|
+
# (ADR-0008 R1). The keys ARE the annotation-declared profile
|
|
579
|
+
# names, so there is no second scan for the name list either.
|
|
580
|
+
_beans = conditional_beans_from_sources_detailed(
|
|
578
581
|
Path(_root), list(sm.file_paths or [])
|
|
579
582
|
)
|
|
583
|
+
_conditional_beans = [b.to_dict() for b in _beans]
|
|
584
|
+
_by_profile: dict[str, set[str]] = {}
|
|
585
|
+
for _bean in _beans:
|
|
586
|
+
for _token in _bean.profiles:
|
|
587
|
+
_by_profile.setdefault(_token.strip(), set()).add(_bean.symbol)
|
|
588
|
+
_conditional = {
|
|
589
|
+
_p: sorted(_names) for _p, _names in sorted(_by_profile.items())
|
|
590
|
+
}
|
|
580
591
|
_declared = sorted(set(
|
|
581
592
|
profiles_from_config(Path(_root))
|
|
582
593
|
+ [p.lstrip("!").strip() for p in _conditional]
|
|
@@ -619,6 +630,19 @@ def _spring_profiles_context(sm: "SourceMap") -> "Optional[dict[str, Any]]":
|
|
|
619
630
|
result["declared_in_code_or_config"] = _declared
|
|
620
631
|
if _conditional:
|
|
621
632
|
result["profile_conditional_beans"] = _conditional
|
|
633
|
+
if _conditional_beans:
|
|
634
|
+
# The map above buckets by token, so `@Profile("a & !b")` appears
|
|
635
|
+
# under both `a` and `!b` with the operator lost. This list keeps
|
|
636
|
+
# the expression as written — it is what `ask posture` resolves,
|
|
637
|
+
# and the only rendering here that can be read as a condition.
|
|
638
|
+
result["conditional_beans"] = _conditional_beans
|
|
639
|
+
result["conditional_beans_note"] = (
|
|
640
|
+
"Symbols carrying @Profile, with the expression as written. "
|
|
641
|
+
"A symbol listed under a token is conditional on that token as "
|
|
642
|
+
"written — '!m3' means active when m3 is ABSENT. Whether a bean "
|
|
643
|
+
"is actually wired under a given profile set is resolved by "
|
|
644
|
+
"`ask posture --profile <set>`, which is the authority."
|
|
645
|
+
)
|
|
622
646
|
result["detection_basis"] = [
|
|
623
647
|
"application-{profile} file naming",
|
|
624
648
|
"spring.config.activate.on-profile / spring.profiles in config documents",
|
|
@@ -2211,11 +2235,14 @@ def agent_view(sm: SourceMap, *, full: bool = False) -> dict[str, Any]:
|
|
|
2211
2235
|
if sm.code_notes_summary.adr_count > 0:
|
|
2212
2236
|
signals["adrs"] = sm.code_notes_summary.adr_count
|
|
2213
2237
|
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2238
|
+
# One authority for "does this repository have tests" (ADR-0008 R1). The
|
|
2239
|
+
# substring rule this replaces answered `true` for a repository whose
|
|
2240
|
+
# production code lives in a package called `test`, contradicting the
|
|
2241
|
+
# `analysis_gaps` block of the same document.
|
|
2242
|
+
from sourcecode.test_sources import analyze_test_sources
|
|
2243
|
+
|
|
2244
|
+
_test_facts = analyze_test_sources(list(sm.file_paths or []))
|
|
2245
|
+
if _test_facts.has_tests:
|
|
2219
2246
|
signals["has_tests"] = True
|
|
2220
2247
|
|
|
2221
2248
|
# Semantic hotspots (populated when --semantics was passed)
|