sourcecode 4.5.3__py3-none-any.whl → 4.7.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/baseline_autocapture.py +13 -1
- sourcecode/cache.py +27 -1
- sourcecode/cli.py +18 -4
- sourcecode/detectors/java.py +4 -0
- sourcecode/explain.py +133 -40
- sourcecode/mcp/registry.py +9 -4
- sourcecode/mcp/server.py +13 -4
- sourcecode/migrate_check.py +24 -1
- sourcecode/path_filters.py +50 -40
- sourcecode/perf.py +112 -1
- sourcecode/posture.py +65 -5
- sourcecode/repository_ir.py +20 -13
- sourcecode/risk.py +119 -31
- sourcecode/rule_catalog.py +10 -3
- sourcecode/security_config_scan.py +172 -0
- sourcecode/source_text.py +2 -3
- sourcecode/spring_impact.py +107 -11
- sourcecode/spring_security_audit.py +20 -2
- sourcecode/test_sources.py +47 -4
- sourcecode/verify_rules.py +1 -1
- {sourcecode-4.5.3.dist-info → sourcecode-4.7.0.dist-info}/METADATA +4 -4
- {sourcecode-4.5.3.dist-info → sourcecode-4.7.0.dist-info}/RECORD +27 -27
- {sourcecode-4.5.3.dist-info → sourcecode-4.7.0.dist-info}/WHEEL +0 -0
- {sourcecode-4.5.3.dist-info → sourcecode-4.7.0.dist-info}/entry_points.txt +0 -0
- {sourcecode-4.5.3.dist-info → sourcecode-4.7.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",
|
|
@@ -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
|
@@ -354,7 +354,7 @@ of files) in minutes. Semantic analysis itself is sub-second; repo indexing domi
|
|
|
354
354
|
ask --agent [dim]# full structured JSON for AI agents[/dim]
|
|
355
355
|
|
|
356
356
|
[bold]Change and risk:[/bold]
|
|
357
|
-
impact-chain <Class> . [dim]# blast radius
|
|
357
|
+
impact-chain <Class> . [dim]# blast radius; TX/SEC findings opt-in[/dim]
|
|
358
358
|
impact <Class> . [dim]# reverse deps → endpoints reached[/dim]
|
|
359
359
|
pr-impact . --files - [dim]# same, scoped to a diff on stdin; gating codes[/dim]
|
|
360
360
|
posture . --since main [dim]# did this branch open an endpoint? (exp.)[/dim]
|
|
@@ -8419,8 +8419,16 @@ def impact_chain_cmd(
|
|
|
8419
8419
|
"surfaces). Totals stay exact in metadata/truncated_lists. "
|
|
8420
8420
|
"0 disables the cap.",
|
|
8421
8421
|
),
|
|
8422
|
+
with_findings: bool = typer.Option(
|
|
8423
|
+
False, "--with-findings",
|
|
8424
|
+
help=(
|
|
8425
|
+
"Also run the repository-wide TX + security audit and filter findings "
|
|
8426
|
+
"to this chain. Off by default so query cost follows the queried "
|
|
8427
|
+
"subgraph rather than the whole repository."
|
|
8428
|
+
),
|
|
8429
|
+
),
|
|
8422
8430
|
) -> None:
|
|
8423
|
-
"""Spring impact-chain: systemic blast radius of a symbol
|
|
8431
|
+
"""Spring impact-chain: systemic blast radius of a symbol; TX/SEC findings are opt-in.
|
|
8424
8432
|
|
|
8425
8433
|
\b
|
|
8426
8434
|
Given a symbol (class or method), returns:
|
|
@@ -8431,6 +8439,7 @@ def impact_chain_cmd(
|
|
|
8431
8439
|
- security_surfaces — per-endpoint security verdict + confidence
|
|
8432
8440
|
(security_posture authority) + declared policy + SEC findings
|
|
8433
8441
|
- impact_findings — TX/SEC audit findings touching the call chain
|
|
8442
|
+
(only with --with-findings)
|
|
8434
8443
|
- risk_level — critical | high | medium | low
|
|
8435
8444
|
|
|
8436
8445
|
\b
|
|
@@ -8537,7 +8546,10 @@ def impact_chain_cmd(
|
|
|
8537
8546
|
)
|
|
8538
8547
|
return
|
|
8539
8548
|
|
|
8540
|
-
result = run_impact_chain(
|
|
8549
|
+
result = run_impact_chain(
|
|
8550
|
+
cir, symbol, depth=depth, root=target, model=_model,
|
|
8551
|
+
include_findings=with_findings,
|
|
8552
|
+
)
|
|
8541
8553
|
|
|
8542
8554
|
data = result.to_dict()
|
|
8543
8555
|
# Same rule as `impact`: a file is a request for the whole answer (C2-2). An
|
|
@@ -8853,7 +8865,9 @@ def explain_cmd(
|
|
|
8853
8865
|
except Exception:
|
|
8854
8866
|
cir = ContextGraph.build(file_list, target).cir # fallback: never break explain
|
|
8855
8867
|
model = SpringSemanticModel.build(cir)
|
|
8856
|
-
|
|
8868
|
+
# `root` is what lets the security section read the request-chain rules a
|
|
8869
|
+
# configuration class declares in a DSL rather than in annotations (C3-41).
|
|
8870
|
+
explanation = explain_class(class_name, cir, model, root=target).capped(limit)
|
|
8857
8871
|
finally:
|
|
8858
8872
|
_prog.finish()
|
|
8859
8873
|
if _cc_look is not None:
|
sourcecode/detectors/java.py
CHANGED
|
@@ -457,6 +457,10 @@ class JavaDetector(AbstractDetector):
|
|
|
457
457
|
or "resteasy" in text # RESTEasy — a JAX-RS implementation
|
|
458
458
|
):
|
|
459
459
|
frameworks.append(FrameworkDetection(name="JAX-RS", source=source))
|
|
460
|
+
if "com.sun.jersey" in text or "org.glassfish.jersey" in text:
|
|
461
|
+
frameworks.append(FrameworkDetection(name="Jersey", source=source))
|
|
462
|
+
if "com.netflix.governator" in text:
|
|
463
|
+
frameworks.append(FrameworkDetection(name="Governator", source=source))
|
|
460
464
|
if "mybatis" in text:
|
|
461
465
|
frameworks.append(FrameworkDetection(name="MyBatis", source=source))
|
|
462
466
|
if "thymeleaf" in text:
|
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)
|
sourcecode/mcp/registry.py
CHANGED
|
@@ -1324,13 +1324,14 @@ min_severity: "low" (default) | "medium" | "high" | "critical"
|
|
|
1324
1324
|
"""
|
|
1325
1325
|
|
|
1326
1326
|
_IMPACT_CHAIN_DOC = """\
|
|
1327
|
-
Spring impact-chain: blast radius of a symbol
|
|
1327
|
+
Spring impact-chain: blast radius of a symbol. TX/SEC findings are opt-in. JAVA/SPRING ONLY.
|
|
1328
1328
|
|
|
1329
1329
|
Do NOT call on non-Java repositories — returns resolution=not_found.
|
|
1330
1330
|
|
|
1331
1331
|
Two query modes via query_type:
|
|
1332
1332
|
"impact" (default) — BFS call graph: direct_callers, indirect_callers, endpoints_affected,
|
|
1333
|
-
transaction_boundary, security_surfaces
|
|
1333
|
+
transaction_boundary, security_surfaces. impact_findings are populated only when
|
|
1334
|
+
with_findings=true, because that runs a repository-wide TX + security audit.
|
|
1334
1335
|
"events" — event topology: publishers, consumers, propagation graph for an event class
|
|
1335
1336
|
or event publisher. Use when symbol is an event class (e.g. OrderPlacedEvent).
|
|
1336
1337
|
|
|
@@ -1344,6 +1345,7 @@ symbol: FQN, class name, or Class#method.
|
|
|
1344
1345
|
repo_path: absolute path to the Java repository (default: current working directory).
|
|
1345
1346
|
depth: BFS traversal depth 1–8 (default 4).
|
|
1346
1347
|
query_type: "impact" (default) | "events"
|
|
1348
|
+
with_findings: false (default) | true to run the repository-wide TX + security audit
|
|
1347
1349
|
"""
|
|
1348
1350
|
|
|
1349
1351
|
spring_audit = _alias_spec(
|
|
@@ -1374,7 +1376,7 @@ query_type: "impact" (default) | "events"
|
|
|
1374
1376
|
|
|
1375
1377
|
impact_chain = _alias_spec(
|
|
1376
1378
|
"impact_chain",
|
|
1377
|
-
"Spring impact-chain: blast radius
|
|
1379
|
+
"Spring impact-chain: blast radius; TX/SEC findings opt-in. JAVA/SPRING ONLY.",
|
|
1378
1380
|
("impact-chain",),
|
|
1379
1381
|
(
|
|
1380
1382
|
ToolParamSpec("symbol", "argument", str, required=True, default=None,
|
|
@@ -1386,6 +1388,9 @@ query_type: "impact" (default) | "events"
|
|
|
1386
1388
|
ToolParamSpec("query_type", "option", str, required=False, default="impact",
|
|
1387
1389
|
option_names=("--type",), choices=("impact", "events"),
|
|
1388
1390
|
help="impact (default) = call-chain blast radius; events = event topology"),
|
|
1391
|
+
ToolParamSpec("with_findings", "option", bool, required=False, default=False,
|
|
1392
|
+
option_names=("--with-findings",), is_flag=True,
|
|
1393
|
+
help="Run TX + security audit and filter findings to this chain."),
|
|
1389
1394
|
),
|
|
1390
1395
|
lambda inputs: [
|
|
1391
1396
|
"impact-chain",
|
|
@@ -1393,7 +1398,7 @@ query_type: "impact" (default) | "events"
|
|
|
1393
1398
|
str(inputs.get("repo_path", ".")),
|
|
1394
1399
|
"--depth", str(inputs.get("depth", 4)),
|
|
1395
1400
|
"--type", str(inputs.get("query_type", "impact")),
|
|
1396
|
-
],
|
|
1401
|
+
] + (["--with-findings"] if inputs.get("with_findings") else []),
|
|
1397
1402
|
supported_targets=("repo_path", "class_name"),
|
|
1398
1403
|
unsupported_targets=("file_path",),
|
|
1399
1404
|
validator=validate_repo_path,
|
sourcecode/mcp/server.py
CHANGED
|
@@ -883,24 +883,31 @@ def get_migration_readiness(repo_path: str = ".", min_severity: str = "low") ->
|
|
|
883
883
|
|
|
884
884
|
|
|
885
885
|
@mcp.tool()
|
|
886
|
-
def get_impact_chain(
|
|
887
|
-
|
|
886
|
+
def get_impact_chain(
|
|
887
|
+
repo_path: str = ".",
|
|
888
|
+
symbol: str = "",
|
|
889
|
+
depth: int = 4,
|
|
890
|
+
with_findings: bool = False,
|
|
891
|
+
) -> dict:
|
|
892
|
+
"""Spring impact-chain: systemic blast radius of a symbol. TX/SEC findings are opt-in. JAVA/SPRING ONLY.
|
|
888
893
|
|
|
889
894
|
Do NOT call this on non-Java repositories — it will return resolution=not_found.
|
|
890
895
|
|
|
891
|
-
Maps to: ask impact-chain <symbol> <repo_path> [--depth <depth>]
|
|
896
|
+
Maps to: ask impact-chain <symbol> <repo_path> [--depth <depth>] [--with-findings]
|
|
892
897
|
Returns: ImpactChainResult with schema_version, symbol, resolution,
|
|
893
898
|
direct_callers, indirect_callers, endpoints_affected,
|
|
894
899
|
transaction_boundary (propagation/isolation/read_only),
|
|
895
900
|
security_surfaces (per-endpoint verdict/confidence from the
|
|
896
901
|
security_posture authority + declared policy + finding IDs),
|
|
897
|
-
impact_findings (TX
|
|
902
|
+
impact_findings (TX/SEC/DEAD findings in call chain, only when
|
|
903
|
+
with_findings=true),
|
|
898
904
|
analysis_warnings, risk_level, confidence, metadata.
|
|
899
905
|
|
|
900
906
|
symbol: FQN, class name, or Class#method. Examples:
|
|
901
907
|
"OrderService", "com.example.OrderService#placeOrder"
|
|
902
908
|
repo_path: absolute path to the Java repository (default: current working directory).
|
|
903
909
|
depth: BFS depth for indirect caller traversal (1–8, default: 4).
|
|
910
|
+
with_findings: run repository-wide TX + security audit and filter findings to this chain.
|
|
904
911
|
"""
|
|
905
912
|
_raw = repo_path
|
|
906
913
|
try:
|
|
@@ -915,6 +922,8 @@ def get_impact_chain(repo_path: str = ".", symbol: str = "", depth: int = 4) ->
|
|
|
915
922
|
if _path_err is not None:
|
|
916
923
|
return _path_err
|
|
917
924
|
args = ["impact-chain", symbol.strip(), repo_path, "--depth", str(depth)]
|
|
925
|
+
if with_findings:
|
|
926
|
+
args.append("--with-findings")
|
|
918
927
|
timeout_ms = int(os.environ.get("SOURCECODE_IMPACT_TIMEOUT_MS", str(_DEFAULT_IMPACT_TIMEOUT_MS)))
|
|
919
928
|
timeout_s = timeout_ms / 1000.0
|
|
920
929
|
_exec = concurrent.futures.ThreadPoolExecutor(max_workers=1)
|
sourcecode/migrate_check.py
CHANGED
|
@@ -1472,6 +1472,11 @@ class MigrationReport:
|
|
|
1472
1472
|
# dimension scores it aggregates) so the headline number is fully traceable.
|
|
1473
1473
|
readiness_aggregate: dict = field(default_factory=dict)
|
|
1474
1474
|
blocking_count: int = 0
|
|
1475
|
+
# Critical/high product findings on dimensions that participate in the
|
|
1476
|
+
# migration-readiness headline. `blocking_count` remains the broad product
|
|
1477
|
+
# blocker count for backward compatibility; this one excludes orthogonal JDK
|
|
1478
|
+
# upkeep that never feeds readiness_score.
|
|
1479
|
+
migration_blocking_count: int = 0
|
|
1475
1480
|
# C1-12: null when the total is not a point (a range slice contributes to it).
|
|
1476
1481
|
# The span always exists — read estimated_effort_range_days for the number.
|
|
1477
1482
|
estimated_effort_days: Optional[float] = 0.0
|
|
@@ -1567,6 +1572,11 @@ class MigrationReport:
|
|
|
1567
1572
|
self.blocking_count = sum(
|
|
1568
1573
|
1 for f in main_findings if f.severity in ("critical", "high")
|
|
1569
1574
|
)
|
|
1575
|
+
self.migration_blocking_count = sum(
|
|
1576
|
+
1 for f in main_findings
|
|
1577
|
+
if f.severity in ("critical", "high")
|
|
1578
|
+
and f.migration_target in _BOOT3_MIGRATION_TARGETS
|
|
1579
|
+
)
|
|
1570
1580
|
|
|
1571
1581
|
# Per-dimension readiness — independent severity-weighted scores (MAIN only).
|
|
1572
1582
|
self.jakarta_readiness = _dimension_score(main_findings, _JAKARTA_TARGETS)
|
|
@@ -1941,6 +1951,7 @@ class MigrationReport:
|
|
|
1941
1951
|
"by_rule": by_rule,
|
|
1942
1952
|
"by_migration_target": by_target,
|
|
1943
1953
|
"main_findings": len(main_findings),
|
|
1954
|
+
"migration_blocking_findings": self.migration_blocking_count,
|
|
1944
1955
|
"non_blocking_findings": self.non_blocking["count"],
|
|
1945
1956
|
"hygiene_findings": self.hygiene_findings,
|
|
1946
1957
|
}
|
|
@@ -1993,10 +2004,21 @@ class MigrationReport:
|
|
|
1993
2004
|
"(jakarta / boot3 / hibernate); see readiness_aggregate for the exact "
|
|
1994
2005
|
"inputs. N/A dimensions are excluded (never counted as 0); "
|
|
1995
2006
|
"jdk_modernization is orthogonal upkeep and is NOT in the aggregate. "
|
|
1996
|
-
"For decisions read the per-dimension breakdown +
|
|
2007
|
+
"For decisions read the per-dimension breakdown + "
|
|
2008
|
+
"migration_blocking_count."
|
|
1997
2009
|
),
|
|
1998
2010
|
"headline_blocker": self.headline_blocker,
|
|
1999
2011
|
"blocking_count": self.blocking_count,
|
|
2012
|
+
"blocking_count_basis": (
|
|
2013
|
+
"critical/high findings in product code across every target, including "
|
|
2014
|
+
"orthogonal JDK modernization"
|
|
2015
|
+
),
|
|
2016
|
+
"migration_blocking_count": self.migration_blocking_count,
|
|
2017
|
+
"migration_blocking_count_basis": (
|
|
2018
|
+
"critical/high product-code findings whose targets feed the migration "
|
|
2019
|
+
"readiness aggregate (jakarta / boot3 / spring_security_6). Excludes "
|
|
2020
|
+
"orthogonal JDK modernization."
|
|
2021
|
+
),
|
|
2000
2022
|
"estimated_effort_days": self.estimated_effort_days,
|
|
2001
2023
|
"estimated_effort_range_days": self.estimated_effort_range_days,
|
|
2002
2024
|
"estimated_effort_note": (
|
|
@@ -2116,6 +2138,7 @@ class MigrationReport:
|
|
|
2116
2138
|
f"(readiness_score reflects jakarta/Boot3 only — Hibernate is a separate rewrite axis)"]
|
|
2117
2139
|
if self.headline_blocker else []),
|
|
2118
2140
|
f"Spring present: {self.spring_present} Spring Boot 2 detected: {_boot}",
|
|
2141
|
+
f"Migration blockers (readiness dimensions): {self.migration_blocking_count}",
|
|
2119
2142
|
f"Blocking issues (product code): {self.blocking_count} "
|
|
2120
2143
|
f"(critical: {main_crit}, high: {main_high})"
|
|
2121
2144
|
+ (f" [+{nb} in test/generated, non-blocking]" if nb else ""),
|