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 +1 -1
- sourcecode/archetype.py +44 -0
- sourcecode/ast_extractor.py +1 -1
- sourcecode/baseline_autocapture.py +13 -1
- sourcecode/cache.py +27 -1
- sourcecode/cli.py +14 -4
- sourcecode/client_calls.py +5 -2
- sourcecode/contract_init.py +56 -15
- sourcecode/data_exposure.py +31 -2
- sourcecode/explain.py +133 -40
- sourcecode/path_filters.py +50 -40
- sourcecode/posture.py +65 -5
- sourcecode/remedies.py +17 -0
- sourcecode/repository_ir.py +20 -13
- sourcecode/ris.py +27 -17
- sourcecode/risk.py +119 -31
- sourcecode/security_config_scan.py +19 -4
- sourcecode/servlet_surface.py +8 -2
- sourcecode/source_text.py +103 -0
- sourcecode/spring_impact.py +66 -6
- sourcecode/test_sources.py +47 -4
- sourcecode/token_estimate.py +1 -1
- sourcecode/verify_edit.py +2 -2
- sourcecode/verify_repo.py +2 -1
- sourcecode/verify_rules.py +32 -12
- {sourcecode-4.5.2.dist-info → sourcecode-4.6.0.dist-info}/METADATA +4 -4
- {sourcecode-4.5.2.dist-info → sourcecode-4.6.0.dist-info}/RECORD +30 -29
- {sourcecode-4.5.2.dist-info → sourcecode-4.6.0.dist-info}/WHEEL +0 -0
- {sourcecode-4.5.2.dist-info → sourcecode-4.6.0.dist-info}/entry_points.txt +0 -0
- {sourcecode-4.5.2.dist-info → sourcecode-4.6.0.dist-info}/licenses/LICENSE +0 -0
sourcecode/__init__.py
CHANGED
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",
|
sourcecode/ast_extractor.py
CHANGED
|
@@ -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=
|
|
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
|
-
|
|
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", *
|
|
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
|
-
|
|
2537
|
-
|
|
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
|
-
|
|
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:
|
sourcecode/client_calls.py
CHANGED
|
@@ -185,9 +185,12 @@ def extract_client_calls(
|
|
|
185
185
|
except OSError:
|
|
186
186
|
continue
|
|
187
187
|
try:
|
|
188
|
-
|
|
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 =
|
|
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):
|
sourcecode/contract_init.py
CHANGED
|
@@ -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
|
|
129
|
-
"""
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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,
|
sourcecode/data_exposure.py
CHANGED
|
@@ -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 `
|
|
156
|
-
"
|
|
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(
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
|
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
|
-
|
|
634
|
-
public_methods
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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)
|