sourcecode 3.2.0__py3-none-any.whl → 3.2.1__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.

@@ -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/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
- conditional_beans_from_sources,
572
+ conditional_beans_from_sources_detailed,
572
573
  profiles_from_config,
573
574
  )
574
575
 
575
- # One source pass: the bean map's keys ARE the annotation-declared
576
- # profile names, so there is no second scan for the name list.
577
- _conditional = conditional_beans_from_sources(
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
- has_tests = any(
2215
- "/test" in p or "/tests" in p or "/spec" in p or p.startswith("test")
2216
- for p in sm.file_paths
2217
- )
2218
- if has_tests:
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)
@@ -153,18 +153,58 @@ _TYPE_AFTER_ANN_RE = re.compile(
153
153
  r"\b(?:class|interface|enum|record)\s+(?P<name>[A-Za-z_$][\w$]*)"
154
154
  )
155
155
 
156
+ # What may stand between `@Profile(...)` and the type declaration it annotates:
157
+ # further annotations and modifiers, nothing else. Anything beyond this means
158
+ # the annotation belongs to a member, not to the type.
159
+ _ANNOTATED_TYPE_RE = re.compile(
160
+ r"\A(?:\s|//[^\n]*\n|/\*.*?\*/"
161
+ r"|@[\w.]+(?:\s*\((?:[^()]|\([^()]*\))*\))?"
162
+ r"|public|protected|private|static|final|abstract|strictfp|sealed|non-sealed)*"
163
+ r"\s*(?:class|interface|enum|record)\s+(?P<name>[A-Za-z_$][\w$]*)",
164
+ re.DOTALL,
165
+ )
156
166
 
157
- def conditional_beans_from_sources(
167
+ # The member an annotation sits on, when it is not a type: the declared name of
168
+ # the next method or field.
169
+ _MEMBER_AFTER_ANN_RE = re.compile(
170
+ r"\A(?:\s|//[^\n]*\n|/\*.*?\*/"
171
+ r"|@[\w.]+(?:\s*\((?:[^()]|\([^()]*\))*\))?"
172
+ r"|public|protected|private|static|final|abstract|synchronized|native|transient|volatile|default"
173
+ r"|<[^<>]*>|[\w.$]+(?:\s*<[^<>]*>)?(?:\s*\[\s*\])*)*?"
174
+ r"\s(?P<name>[A-Za-z_$][\w$]*)\s*[(=;]",
175
+ re.DOTALL,
176
+ )
177
+
178
+
179
+ def _enclosing_type_name(text: str, pos: int, fallback: str) -> str:
180
+ """Name of the type declaration that encloses ``pos``."""
181
+ last = None
182
+ for m in _TYPE_AFTER_ANN_RE.finditer(text, 0, pos):
183
+ last = m
184
+ return last.group("name") if last else fallback
185
+
186
+
187
+ def conditional_beans_from_sources_detailed(
158
188
  root: Path, file_paths: "Optional[list[str]]" = None
159
- ) -> "dict[str, list[str]]":
160
- """`{profile: [TypeName, …]}` for types carrying `@Profile`.
189
+ ) -> "list[ProfileConditionalBean]":
190
+ """`@Profile` symbols and the expression each one declares, from sources.
191
+
192
+ The single derivation of this fact for callers that hold no CIR;
193
+ :func:`profile_conditional_beans` is the same fact read from the IR, and
194
+ both must agree — a symbol this reports under a profile must be a symbol
195
+ `posture` resolves the same way.
161
196
 
162
- Which beans a profile switches is the fact worth reporting — a profile list
163
- alone does not tell a reader that one profile swaps the security filter
164
- chain. Used where no CIR is in scope; `profile_conditional_beans` is the
165
- exact, IR-based version.
197
+ **Attribution rule (the defect this exists to prevent):** a `@Profile` is
198
+ attributed to the declaration it actually annotates. Only annotations and
199
+ modifiers may stand between it and a type declaration; anything else means
200
+ the annotation sits on a member, which is then reported as ``Type#member``.
201
+ The previous scan searched for the *next* type declaration anywhere ahead
202
+ and fell back to the file name, so a `@Profile("m3")` on a `@Bean` method
203
+ inside `DevSecurityConfig` was reported as `DevSecurityConfig` being
204
+ conditional on `m3` — the exact opposite of its class-level
205
+ `@Profile("!m3")`, in the same document, contradicting `posture`.
166
206
  """
167
- out: dict[str, set[str]] = {}
207
+ beans: list[ProfileConditionalBean] = []
168
208
  try:
169
209
  paths = (
170
210
  [Path(root) / p for p in file_paths if p.endswith(".java")]
@@ -172,7 +212,7 @@ def conditional_beans_from_sources(
172
212
  else list(Path(root).rglob("*.java"))
173
213
  )
174
214
  except Exception:
175
- return {}
215
+ return []
176
216
  for path in paths[:20000]:
177
217
  try:
178
218
  text = path.read_text(encoding="utf-8", errors="ignore")
@@ -180,11 +220,51 @@ def conditional_beans_from_sources(
180
220
  continue
181
221
  if "@Profile" not in text:
182
222
  continue
223
+ try:
224
+ rel = str(path.relative_to(Path(root)))
225
+ except Exception:
226
+ rel = str(path)
183
227
  for match in _PROFILE_ANN_RE.finditer(text):
184
- type_match = _TYPE_AFTER_ANN_RE.search(text, match.end())
185
- name = type_match.group("name") if type_match else path.stem
186
- for token in _split_profile_expression(match.group("args")):
187
- out.setdefault(token.strip(), set()).add(name)
228
+ tail = text[match.end():match.end() + 4000]
229
+ type_match = _ANNOTATED_TYPE_RE.match(tail)
230
+ if type_match:
231
+ symbol = type_match.group("name")
232
+ else:
233
+ member_match = _MEMBER_AFTER_ANN_RE.match(tail)
234
+ if not member_match:
235
+ # Neither a type nor a readable member: report nothing
236
+ # rather than attribute the annotation to a guess.
237
+ continue
238
+ owner = _enclosing_type_name(text, match.start(), path.stem)
239
+ symbol = f"{owner}#{member_match.group('name')}"
240
+ args = match.group("args")
241
+ names = _split_profile_expression(args)
242
+ if not names:
243
+ continue
244
+ beans.append(ProfileConditionalBean(
245
+ symbol=symbol,
246
+ profiles=tuple(names),
247
+ source_file=rel,
248
+ expression=args.strip(),
249
+ ))
250
+ beans.sort(key=lambda b: (b.symbol, b.profiles))
251
+ return beans
252
+
253
+
254
+ def conditional_beans_from_sources(
255
+ root: Path, file_paths: "Optional[list[str]]" = None
256
+ ) -> "dict[str, list[str]]":
257
+ """`{profile: [symbol, …]}` — a projection of
258
+ :func:`conditional_beans_from_sources_detailed`.
259
+
260
+ Kept as the map shape published in the signals payload. It loses the
261
+ operators (`"a & !b"` appears under `a` and under `!b`), which is why the
262
+ detailed list carries `expression` and is what a reader should consume.
263
+ """
264
+ out: dict[str, set[str]] = {}
265
+ for bean in conditional_beans_from_sources_detailed(root, file_paths):
266
+ for token in bean.profiles:
267
+ out.setdefault(token.strip(), set()).add(bean.symbol)
188
268
  return {profile: sorted(names) for profile, names in sorted(out.items())}
189
269
 
190
270