sourcecode 4.5.2__py3-none-any.whl → 4.6.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__ = "4.5.2"
7
+ __version__ = "4.6.0"
sourcecode/archetype.py CHANGED
@@ -86,6 +86,14 @@ _MIN_CONFIDENT_SCORE = 0.5
86
86
  # discriminating. Tuned so a clear library (no app/server/servlet entry) is not confidently
87
87
  # labeled an engine, while runnable systems (which never trigger the gate) are untouched.
88
88
  _LIBRARY_ENGINE_DISCOUNT = 0.55
89
+ # The same shape for the opposite mistake (C3-43): a repository whose primary
90
+ # surface is HTTP owns storage/kernel packages and hub topology *because* it
91
+ # serves requests with them, so that evidence is not discriminating for "engine"
92
+ # either. Scaled by the measured density, so a repository with a marginal surface
93
+ # is barely discounted and one that is nothing but endpoints is discounted fully.
94
+ # Below the library figure on purpose: an application CAN be built around an
95
+ # engine, whereas a non-runnable artifact cannot be an engine product at all.
96
+ _HTTP_ENGINE_DISCOUNT = 0.45
89
97
  # Entry kinds that make a repo a runnable system (an app/server/servlet/daemon) rather
90
98
  # than a consumable library. Used to gate the library de-bias (P0).
91
99
  _RUNNABLE_ENTRY_KINDS = frozenset({
@@ -409,6 +417,42 @@ class ArchetypeClassifier:
409
417
  add("application", "runtime_entry_plus_services",
410
418
  f"bootstrap/server entry + service mass ≈ {cm['service']:.0%}",
411
419
  2.0, 1.0, max(cm["service"], 0.2))
420
+ # C3-43. The strongest MEASURED discriminator this build has for what a
421
+ # repository is — its HTTP surface — was read by `_primary_interface` and
422
+ # ignored here, so a 3 574-endpoint CRUD REST monolith was labelled
423
+ # `engine` on package-name masses and a fan-in Gini, while the legacy
424
+ # classifier it supersedes answered `api` and was right. Same ramp as the
425
+ # interface dimension, from the same features: one authority for the
426
+ # figure, two dimensions reading it.
427
+ if f.endpoint_share is None:
428
+ # Unmeasured is not zero: contribute nothing, and say so.
429
+ add("application", "http_surface_density",
430
+ "endpoint surface not measured — this dimension is scored without it",
431
+ 2.5, 0.0, 0.0)
432
+ else:
433
+ density = f.endpoint_share * 100
434
+ http_strength = (
435
+ max(0.0, min(1.0, (density - 0.5) / 1.5)) if f.endpoint_total else 0.0
436
+ )
437
+ add("application", "http_surface_density",
438
+ f"{f.endpoint_total} endpoints over {f.total_files} files "
439
+ f"({density:.2f}/100 files) — the repository's primary surface is HTTP",
440
+ 2.5, http_strength, 1.0)
441
+ # And the same fact as NAMED negative evidence for `engine`, the way
442
+ # the library de-bias below does it: a codebase whose product is a
443
+ # request surface owns storage and kernel packages *because* it is an
444
+ # application, so that mass is not, alone, evidence of an engine.
445
+ engine_positive_http = sum(max(0.0, e.contribution) for e in cands["engine"])
446
+ if http_strength > 0 and engine_positive_http > 0:
447
+ cands["engine"].append(Evidence(
448
+ "http_surface_discount",
449
+ f"{f.endpoint_total} endpoints over {f.total_files} files: the "
450
+ "primary surface of this repository is HTTP, and the storage/"
451
+ "kernel mass and hub topology an application needs to serve it "
452
+ "are not, alone, evidence of an engine product",
453
+ 1.0, 1.0, 1.0,
454
+ -engine_positive_http * _HTTP_ENGINE_DISCOUNT * http_strength,
455
+ ))
412
456
  # platform: many modules + multiple concept clusters + distribution
413
457
  multi = 1.0 if f.module_count >= 8 else f.module_count / 8
414
458
  add("platform", "many_modules_multi_concern",
@@ -1252,7 +1252,7 @@ class AstExtractor:
1252
1252
  stat = path.stat()
1253
1253
  if stat.st_size > self.max_file_size:
1254
1254
  return FileContract(
1255
- path=str(path.relative_to(root)) if root else path.name,
1255
+ path=rel_path,
1256
1256
  language=language,
1257
1257
  extraction_method="heuristic",
1258
1258
  limitations=[f"file_too_large: {stat.st_size} bytes > {self.max_file_size}"],
@@ -210,7 +210,19 @@ def worktree_dirty(
210
210
  return None
211
211
  if out.returncode != 0:
212
212
  return None
213
- lines = [ln for ln in out.stdout.splitlines() if ln.strip()]
213
+ # C1-27. "Dirty" means the tree *the analysis reads* differs from HEAD. A
214
+ # change under an editor or agent state directory is not one — no analyser
215
+ # opens those files — and counting it reported STALE on a snapshot that
216
+ # described the tree exactly, with `Delta: 0` beside it. One authority answers
217
+ # which paths qualify (`path_filters.analysis_reads`) and its default is
218
+ # inclusive: anything not named there still counts, because a missed change
219
+ # is a false *fresh* and a spurious one only costs a rebuild.
220
+ from sourcecode.path_filters import analysis_reads
221
+
222
+ lines = [
223
+ ln for ln in out.stdout.splitlines()
224
+ if ln.strip() and analysis_reads(_porcelain_path(ln))
225
+ ]
214
226
  if ignore is None:
215
227
  return bool(lines)
216
228
  candidates = [ignore] if isinstance(ignore, (str, Path)) else list(ignore)
sourcecode/cache.py CHANGED
@@ -213,6 +213,14 @@ def _untracked_tree_fingerprint(target: Path) -> str:
213
213
  return f"ff{h.hexdigest()[:14]}"
214
214
 
215
215
 
216
+ def _porcelain_status_path(line: str) -> str:
217
+ """The path a `git status --porcelain` line refers to (destination on renames)."""
218
+ body = line[3:] if len(line) > 3 else ""
219
+ if " -> " in body:
220
+ body = body.split(" -> ", 1)[1]
221
+ return body.strip().strip('"')
222
+
223
+
216
224
  def worktree_signature(repo_root: Path, scope: Optional[Path] = None) -> str:
217
225
  """Hex fingerprint of the **exact tree state an analysis would read**.
218
226
 
@@ -252,12 +260,30 @@ def worktree_signature(repo_root: Path, scope: Optional[Path] = None) -> str:
252
260
  porcelain = st.stdout if st.returncode == 0 else ""
253
261
  except Exception:
254
262
  porcelain = ""
263
+ # C1-27. The signature describes *the tree an analysis would read*, so a line
264
+ # about a file no analyser opens does not belong in it — an editor settings
265
+ # file was invalidating every cached answer and publishing STALE beside
266
+ # `Delta: 0`. One authority decides which paths qualify, and its default is
267
+ # inclusive: anything not named there still invalidates.
268
+ from sourcecode.path_filters import analysis_reads
269
+
270
+ porcelain = "\n".join(
271
+ ln for ln in porcelain.splitlines()
272
+ if ln.strip() and analysis_reads(_porcelain_status_path(ln))
273
+ )
255
274
  if not porcelain.strip():
256
275
  return head # clean — HEAD fully describes the tree
257
276
 
258
277
  try:
278
+ from sourcecode.path_filters import _TOOL_STATE_DIRS
279
+
280
+ # The same exclusion, expressed to git for the half that is not a list of
281
+ # paths. A tracked editor-state file must not enter the signature through
282
+ # the diff after being kept out of the status.
283
+ _diff_spec = list(pathspec) or ["--", "."]
284
+ _diff_spec += [f":(exclude,glob)**/{d}/**" for d in sorted(_TOOL_STATE_DIRS)]
259
285
  df = subprocess.run(
260
- ["git", "-C", str(root), "diff", "HEAD", *pathspec],
286
+ ["git", "-C", str(root), "diff", "HEAD", *_diff_spec],
261
287
  capture_output=True, text=True, timeout=20,
262
288
  )
263
289
  diff_txt = df.stdout if df.returncode == 0 else ""
sourcecode/cli.py CHANGED
@@ -2533,9 +2533,17 @@ def main(
2533
2533
  [l for l in _uc_r.stdout.splitlines() if l.strip()]
2534
2534
  )
2535
2535
  _patched["git_context"]["uncommitted_files"] = _uc_count
2536
- _patched["git_context"]["_stale_fields_refreshed"] = [
2537
- "uncommitted_files"
2538
- ]
2536
+ # Provenance goes in `_meta`, never inside the payload
2537
+ # block. Written into `git_context` it changed that
2538
+ # block's *shape* depending on whether the answer came
2539
+ # from cache, so one request answered two ways compared
2540
+ # unequal on a key that describes the run rather than
2541
+ # the repository (surfaced by C3-40, which made an
2542
+ # untracked file dirty again and put this path back in
2543
+ # reach).
2544
+ _meta_gc = _patched.setdefault("_meta", {})
2545
+ if isinstance(_meta_gc, dict):
2546
+ _meta_gc["stale_fields_refreshed"] = ["git_context.uncommitted_files"]
2539
2547
  _cache_hit_content = _json_gc.dumps(
2540
2548
  _patched, indent=2, ensure_ascii=False
2541
2549
  )
@@ -8845,7 +8853,9 @@ def explain_cmd(
8845
8853
  except Exception:
8846
8854
  cir = ContextGraph.build(file_list, target).cir # fallback: never break explain
8847
8855
  model = SpringSemanticModel.build(cir)
8848
- explanation = explain_class(class_name, cir, model).capped(limit)
8856
+ # `root` is what lets the security section read the request-chain rules a
8857
+ # configuration class declares in a DSL rather than in annotations (C3-41).
8858
+ explanation = explain_class(class_name, cir, model, root=target).capped(limit)
8849
8859
  finally:
8850
8860
  _prog.finish()
8851
8861
  if _cc_look is not None:
@@ -185,9 +185,12 @@ def extract_client_calls(
185
185
  except OSError:
186
186
  continue
187
187
  try:
188
- relative = str(file_path.relative_to(base))
188
+ # POSIX like every other published path (C2-24). A client-usage row
189
+ # exists to be joined against a route's declaring file, and a join
190
+ # on path fails silently when the two sides spell it differently.
191
+ relative = file_path.relative_to(base).as_posix()
189
192
  except ValueError:
190
- relative = str(file_path)
193
+ relative = Path(file_path).as_posix()
191
194
  for match in _VERB_CALL.finditer(text):
192
195
  receiver = match.group("receiver") or ""
193
196
  if not _HTTP_RECEIVER.search(receiver):
@@ -125,24 +125,52 @@ def _security_rules(cir: Any) -> tuple[list[dict], list[dict]]:
125
125
  return proposed, rejected
126
126
 
127
127
 
128
- def _finding_rules(cir: Any) -> tuple[list[dict], list[dict]]:
129
- """`forbid_finding` for audit patterns that fire nowhere today."""
130
- from sourcecode.spring_security_audit import _DEFAULT_SECURITY_PATTERNS
128
+ def _rule_catalogue() -> "list[tuple[str, str]]":
129
+ """Every rule this build ships, as `(pattern_id, severity)`.
130
+
131
+ Read from `rule_catalog.RULES` — the one authority `--help`, the README and
132
+ the rule reference already render from, and the one a test binds to the
133
+ pattern registries so a new rule cannot exist without a row.
134
+
135
+ C1-24's second half: this list used to be built from the two *pattern
136
+ registries* alone, which stop at SEC-004. SEC-005, SEC-006 and SEC-007 are
137
+ produced by `security_config_scan` and appeared in **neither** the proposed
138
+ nor the rejected list — a rule could be silently outside contract derivation
139
+ with nothing saying so, which is the failure mode a "not proposed, and why"
140
+ list exists to prevent.
141
+ """
142
+ from sourcecode.rule_catalog import RULES
143
+ from sourcecode.spring_security_audit import (
144
+ _DEFAULT_SECURITY_PATTERNS,
145
+ SecurityScanner,
146
+ )
131
147
  from sourcecode.spring_tx_analyzer import _DEFAULT_TX_PATTERNS
148
+
149
+ severities: "dict[str, str]" = {}
150
+ for pattern in (*_DEFAULT_TX_PATTERNS, *_DEFAULT_SECURITY_PATTERNS):
151
+ pattern_id = getattr(pattern, "pattern_id", "")
152
+ if pattern_id:
153
+ severities[pattern_id] = getattr(pattern, "severity", "low")
154
+ # The configuration rules carry their severity on the scanner that emits
155
+ # them, not on a pattern object; same authority, different shape.
156
+ severities.update(getattr(SecurityScanner, "_SEVERITY", {}) or {})
157
+ return sorted(
158
+ (rule.id, severities.get(rule.id, "low")) for rule in RULES if rule.id
159
+ )
160
+
161
+
162
+ def _finding_rules(cir: Any, root: "Optional[Path]" = None) -> tuple[list[dict], list[dict]]:
163
+ """`forbid_finding` for audit patterns that fire nowhere today."""
132
164
  from sourcecode.verify_rules import _audit_findings
133
165
 
134
- findings = _audit_findings(cir)
166
+ # Same `root` the gate evaluates with: without it the configuration rules
167
+ # cannot fire, and every one of them would be proposed as "fires nowhere".
168
+ findings = _audit_findings(cir, root)
135
169
  firing = Counter(f.pattern_id for f in findings)
136
170
 
137
171
  proposed: list[dict] = []
138
172
  rejected: list[dict] = []
139
- catalogue = [
140
- (getattr(p, "pattern_id", ""), getattr(p, "severity", "low"))
141
- for p in (*_DEFAULT_TX_PATTERNS, *_DEFAULT_SECURITY_PATTERNS)
142
- ]
143
- for pattern_id, severity in sorted(set(catalogue)):
144
- if not pattern_id:
145
- continue
173
+ for pattern_id, severity in _rule_catalogue():
146
174
  count = firing.get(pattern_id, 0)
147
175
  if count:
148
176
  rejected.append({
@@ -283,7 +311,9 @@ def _slug(annotation: str) -> str:
283
311
  return annotation.lstrip("@").replace(".", "-").lower()
284
312
 
285
313
 
286
- def execute_candidates(candidates: list[dict], cir: Any) -> tuple[list[dict], list[dict]]:
314
+ def execute_candidates(
315
+ candidates: list[dict], cir: Any, root: "Optional[Path]" = None
316
+ ) -> tuple[list[dict], list[dict]]:
287
317
  """``(kept, dropped)`` — run every candidate before publishing it.
288
318
 
289
319
  The check that makes this a baseline rather than a wish list, and the one
@@ -292,6 +322,12 @@ def execute_candidates(candidates: list[dict], cir: Any) -> tuple[list[dict], li
292
322
  the engine `verify` runs and drops anything that disagrees, so a derivation
293
323
  that grows a blind spot cannot ship a red gate on the commit it was
294
324
  generated from.
325
+
326
+ C1-24: this net had the hole it was meant to catch. It evaluated without a
327
+ `root`, so the security *configuration* rules could not fire here either, and
328
+ `no-sec-004` passed the check that exists to stop exactly that rule from
329
+ being published. A safety net blind in the same way as the thing it guards
330
+ is not a second opinion.
295
331
  """
296
332
  from sourcecode.verify_rules import evaluate_all, parse_rules
297
333
 
@@ -300,7 +336,7 @@ def execute_candidates(candidates: list[dict], cir: Any) -> tuple[list[dict], li
300
336
  for rule in candidates:
301
337
  payload = {k: v for k, v in rule.items() if not k.startswith("_")}
302
338
  try:
303
- violations = evaluate_all(parse_rules({"contracts": [payload]}), cir)
339
+ violations = evaluate_all(parse_rules({"contracts": [payload]}), cir, root)
304
340
  except Exception as exc: # a rule this module built must never break the run
305
341
  dropped.append({"id": rule.get("id", "?"), "reason": f"did not parse: {exc}"})
306
342
  continue
@@ -333,12 +369,17 @@ def derive_contracts(root: Path, cir: Optional[Any] = None) -> dict:
333
369
 
334
370
  proposed: list[dict] = []
335
371
  rejected: list[dict] = []
372
+ # `_finding_rules` needs the root; the graph-only derivations do not. Passed
373
+ # by name rather than positionally so adding a third file-reading derivation
374
+ # is a one-word change instead of a silent blind spot (C1-24).
336
375
  for derive in (_security_rules, _finding_rules, _edge_rules):
337
- rules, skipped = derive(cir)
376
+ rules, skipped = (
377
+ derive(cir, root) if derive is _finding_rules else derive(cir)
378
+ )
338
379
  proposed.extend(rules)
339
380
  rejected.extend(skipped)
340
381
 
341
- kept, dropped = execute_candidates(proposed, cir)
382
+ kept, dropped = execute_candidates(proposed, cir, root)
342
383
 
343
384
  return {
344
385
  "schema_version": SCHEMA_VERSION,
@@ -146,15 +146,44 @@ def build_data_exposure(
146
146
  if not decl.labels:
147
147
  # No declaration is not "nothing is exposed" — it is a question nobody asked
148
148
  # yet, and the counts stay null rather than reading as a clean bill (I-3/R9).
149
+ #
150
+ # C4-14: the statement said what to do and the payload gave the reader no
151
+ # way to do it — "cero valor out-of-the-box, cero guía de onboarding en la
152
+ # propia salida", on the payload aimed at a regulated buyer. The file name,
153
+ # the key and the shape are read from `data_labels`, the module that parses
154
+ # them, so an example that stops being valid fails the build instead of
155
+ # teaching a buyer the wrong schema.
156
+ from sourcecode.data_labels import CONFIG_FILENAME, CONFIG_KEY
157
+ from sourcecode.remedies import remedy as _remedy
158
+
149
159
  payload.update(
150
160
  {
151
161
  "answered": False,
152
162
  "statement": (
153
163
  "No data labels are declared, so this run measured nothing. A "
154
164
  "label is a judgement about a domain and is never inferred from a "
155
- "field name: declare one under `dataLabels` in "
156
- "sourcecode.config.json and re-run."
165
+ f"field name: declare one under `{CONFIG_KEY}` in "
166
+ f"{CONFIG_FILENAME} and re-run."
157
167
  ),
168
+ "remedy": _remedy("no_data_labels_declared").to_dict(),
169
+ "declare": {
170
+ "file": CONFIG_FILENAME,
171
+ "key": CONFIG_KEY,
172
+ "example": {
173
+ CONFIG_KEY: [
174
+ {
175
+ "label": "pii",
176
+ "types": ["com.example.Person"],
177
+ "fields": ["com.example.Account#iban"],
178
+ }
179
+ ]
180
+ },
181
+ "note": (
182
+ "`types` labels every route whose signature names the type; "
183
+ "`fields` labels the declaring type through the member. Both "
184
+ "are fully-qualified, and neither is matched by name shape."
185
+ ),
186
+ },
158
187
  "summary": {
159
188
  "labels": 0,
160
189
  "seeds": 0,
sourcecode/explain.py CHANGED
@@ -15,6 +15,7 @@ are transitional and tracked for a later phase.
15
15
  from __future__ import annotations
16
16
 
17
17
  from dataclasses import dataclass, field
18
+ from pathlib import Path
18
19
  from typing import TYPE_CHECKING, Optional
19
20
 
20
21
  from sourcecode.caller_metrics import CALLER_METRIC_RECONCILIATION
@@ -397,7 +398,15 @@ def _structural_purpose(
397
398
 
398
399
 
399
400
  def _build_public_methods(class_fqn: str, raw_nodes: list[dict]) -> list[str]:
400
- """Return public method names for class_fqn from raw_ir nodes."""
401
+ """Return public method names for class_fqn from raw_ir nodes.
402
+
403
+ C3-41: a factory member (`symbol_kind: "bean"`) is a method, and it is the
404
+ *whole* API of a configuration class. Excluding the kind reported
405
+ `public_methods: []` for every `@Configuration` in every repository. Its
406
+ Java visibility is not the test either — a factory member is called by the
407
+ container, not by a caller in this repository, so a package-private one is
408
+ still the class's published surface.
409
+ """
401
410
  prefix = class_fqn + "#"
402
411
  methods: list[str] = []
403
412
  for node in raw_nodes:
@@ -405,6 +414,11 @@ def _build_public_methods(class_fqn: str, raw_nodes: list[dict]) -> list[str]:
405
414
  if not fqn.startswith(prefix):
406
415
  continue
407
416
  kind = node.get("symbol_kind") or node.get("type") or ""
417
+ if kind == "bean":
418
+ name = fqn[len(prefix):]
419
+ if name and not name.startswith("<"):
420
+ methods.append(name)
421
+ continue
408
422
  if kind not in ("method", "endpoint", "constructor", ""):
409
423
  continue
410
424
  modifiers: list[str] = node.get("modifiers") or []
@@ -465,10 +479,45 @@ def _build_callers(
465
479
  return [_simple(f) if counts[_simple(f)] == 1 else f for f in fqns]
466
480
 
467
481
 
468
- def _build_deps(class_fqn: str, graph: "ContextGraph") -> list[str]:
469
- """DI injected dependencies, simple names — via the ContextGraph public API."""
470
- deps = graph.injected_dependencies_of(class_fqn)
471
- return sorted({_simple(d) for d in deps})
482
+ def _build_deps(
483
+ class_fqn: str, graph: "ContextGraph", raw_nodes: "Optional[list[dict]]" = None
484
+ ) -> list[str]:
485
+ """What this class depends on, simple names — via the ContextGraph public API.
486
+
487
+ Injected dependencies for a class that is wired; **plus what a factory
488
+ declares**, for a class that does the wiring (C3-41). A configuration class
489
+ injects nothing: it names its collaborators in the signatures of its factory
490
+ members and builds them in their bodies, so reading only the injection graph
491
+ reported `outgoing_deps: []` for a class with eight collaborators per method.
492
+
493
+ Evidence, not naming: the extra sources are read only when the class actually
494
+ declares factory members, and both are atoms the IR already publishes — the
495
+ class-scope type surface and the instantiation facts.
496
+ """
497
+ deps = set(graph.injected_dependencies_of(class_fqn))
498
+ if _factory_members(class_fqn, raw_nodes or []):
499
+ deps.update(
500
+ ref.type for ref in graph.class_type_references_in(class_fqn) if ref.type
501
+ )
502
+ for member in _factory_members(class_fqn, raw_nodes or []):
503
+ deps.update(inst.type for inst in graph.instantiations_in(member) if inst.type)
504
+ return sorted({_simple(d) for d in deps if d})
505
+
506
+
507
+ def _factory_members(class_fqn: str, raw_nodes: list[dict]) -> list[str]:
508
+ """Members of this class that declare a bean rather than behaviour.
509
+
510
+ The IR already classifies them (`symbol_kind: "bean"`); this is the one place
511
+ that reads it, so both the method list and the dependency list agree about
512
+ what a configuration class contains.
513
+ """
514
+ prefix = class_fqn + "#"
515
+ return [
516
+ str(node.get("fqn"))
517
+ for node in raw_nodes
518
+ if str(node.get("fqn") or "").startswith(prefix)
519
+ and (node.get("symbol_kind") or "") == "bean"
520
+ ]
472
521
 
473
522
 
474
523
  def _build_events_published(class_fqn: str, model: "SpringSemanticModel") -> list[str]:
@@ -566,6 +615,44 @@ def _build_security(
566
615
  return result
567
616
 
568
617
 
618
+ def _build_chain_rules(class_fqn: str, cir: "CanonicalRepositoryIR", root) -> list[str]:
619
+ """Access rules this class declares for the request chain.
620
+
621
+ C3-41. A class that configures the chain writes its constraints in a DSL, not
622
+ in annotations, so the annotation reader returned `[]` for the one class whose
623
+ entire job is deciding access — while `posture`, in the same build, read its
624
+ rules from that very file. One authority answers it (`chain_rules`), so the
625
+ two commands cannot describe the same file differently.
626
+ """
627
+ if root is None:
628
+ return []
629
+ from sourcecode.chain_rules import rules_from_source
630
+
631
+ rel = _source_file_of(class_fqn, cir)
632
+ if not rel:
633
+ return []
634
+ try:
635
+ source = (Path(root) / rel).read_text(encoding="utf-8", errors="replace")
636
+ except OSError:
637
+ return []
638
+ out: list[str] = []
639
+ for rule in rules_from_source(source, rel):
640
+ patterns = ", ".join(rule.patterns) if rule.patterns else "**"
641
+ out.append(
642
+ f"request chain: {patterns} → {rule.decision} "
643
+ f"({rel}:{rule.line})"
644
+ )
645
+ return out
646
+
647
+
648
+ def _source_file_of(class_fqn: str, cir: "CanonicalRepositoryIR") -> str:
649
+ """The repo-relative file declaring this class, from the IR's own node."""
650
+ for node in _get_raw_nodes(cir):
651
+ if node.get("fqn") == class_fqn:
652
+ return str(node.get("file") or node.get("source_file") or "")
653
+ return ""
654
+
655
+
569
656
  def _build_endpoints(class_fqn: str, model: "SpringSemanticModel") -> list[str]:
570
657
  """REST endpoints declared on this controller class."""
571
658
  endpoints = model.endpoint_index.endpoints_for(class_fqn)
@@ -585,13 +672,27 @@ def explain_class(
585
672
  class_name: str,
586
673
  cir: "CanonicalRepositoryIR",
587
674
  model: "SpringSemanticModel",
675
+ root: "Optional[Path]" = None,
588
676
  ) -> ClassExplanation:
589
677
  """Build a ClassExplanation for class_name from existing CIR + model.
590
678
 
591
- Never raises — wraps all derivation in try/except.
679
+ Never raises. C3-41: a section that *failed* now says so. Every section was
680
+ wrapped in a bare `except: []`, which made a crash and a measured emptiness
681
+ the same output — and an empty section reads as *"there are none"*.
592
682
  """
593
683
  warnings: list[str] = []
594
684
 
685
+ def _section_of(name: str, build, default):
686
+ """Run one section builder; a failure becomes a warning, not a silence."""
687
+ try:
688
+ return build()
689
+ except Exception as exc: # pragma: no cover — defensive by contract
690
+ warnings.append(
691
+ f"{name} could not be derived ({type(exc).__name__}): this section "
692
+ f"is empty because the derivation failed, not because there is none"
693
+ )
694
+ return default
695
+
595
696
  try:
596
697
  class_fqn, all_matches = _resolve_fqn(class_name, cir)
597
698
  except Exception:
@@ -630,40 +731,32 @@ def explain_class(
630
731
  except Exception:
631
732
  purpose = f"{stereotype} class"
632
733
 
633
- try:
634
- public_methods = _build_public_methods(class_fqn, raw_nodes)
635
- except Exception:
636
- public_methods = []
637
-
638
- try:
639
- incoming_callers = _build_callers(class_fqn, cir, graph)
640
- except Exception:
641
- incoming_callers = []
642
-
643
- try:
644
- outgoing_deps = _build_deps(class_fqn, graph)
645
- except Exception:
646
- outgoing_deps = []
647
-
648
- try:
649
- events_published = _build_events_published(class_fqn, model)
650
- except Exception:
651
- events_published = []
652
-
653
- try:
654
- events_consumed = _build_events_consumed(class_fqn, model)
655
- except Exception:
656
- events_consumed = []
657
-
658
- try:
659
- transactions = _build_transactions(class_fqn, model)
660
- except Exception:
661
- transactions = []
662
-
663
- try:
664
- security_constraints = _build_security(class_fqn, raw_nodes, cir)
665
- except Exception:
666
- security_constraints = []
734
+ public_methods = _section_of(
735
+ "public_methods", lambda: _build_public_methods(class_fqn, raw_nodes), []
736
+ )
737
+ incoming_callers = _section_of(
738
+ "incoming_callers", lambda: _build_callers(class_fqn, cir, graph), []
739
+ )
740
+ outgoing_deps = _section_of(
741
+ "outgoing_deps", lambda: _build_deps(class_fqn, graph, raw_nodes), []
742
+ )
743
+ events_published = _section_of(
744
+ "events_published", lambda: _build_events_published(class_fqn, model), []
745
+ )
746
+ events_consumed = _section_of(
747
+ "events_consumed", lambda: _build_events_consumed(class_fqn, model), []
748
+ )
749
+ transactions = _section_of(
750
+ "transactions", lambda: _build_transactions(class_fqn, model), []
751
+ )
752
+ security_constraints = _section_of(
753
+ "security_constraints", lambda: _build_security(class_fqn, raw_nodes, cir), []
754
+ )
755
+ # A class that configures the request chain declares its constraints in a
756
+ # DSL. Same section, one authority (`chain_rules`) — never a second parser.
757
+ security_constraints = security_constraints + _section_of(
758
+ "security_constraints", lambda: _build_chain_rules(class_fqn, cir, root), []
759
+ )
667
760
 
668
761
  try:
669
762
  rest_endpoints = _build_endpoints(class_fqn, model)