sourcecode 1.38.0__py3-none-any.whl → 1.41.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.
sourcecode/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """sourcecode — Deterministic codebase context maps for AI coding agents."""
2
2
 
3
- __version__ = "1.38.0"
3
+ __version__ = "1.41.0"
sourcecode/cir_graphs.py CHANGED
@@ -25,22 +25,67 @@ class ImplementationGraph:
25
25
 
26
26
  Built from implements edges where BOTH ends are known CIR symbols (internal
27
27
  interface/class pairs). External framework interfaces are excluded.
28
+
29
+ Subtype indices (CH-001c): `extends` edges are also captured so that
30
+ interface-to-interface inheritance (`SubIface extends BaseIface`) and abstract
31
+ base classes (`SubClass extends BaseClass`) are modeled as descendants of the
32
+ supertype. The `implements`-only indices (`_impl_of`/`_ifaces_of`) are kept
33
+ separate to preserve DI resolution semantics (primary_implementation).
28
34
  """
29
35
  _impl_of: dict[str, list[str]] = field(default_factory=dict)
30
36
  _ifaces_of: dict[str, list[str]] = field(default_factory=dict)
37
+ # CH-001c: union of implements + extends descendants (impl classes, sub-interfaces,
38
+ # subclasses) keyed by supertype FQN, and its reverse.
39
+ _subtype_of: dict[str, list[str]] = field(default_factory=dict)
40
+ _supertype_of: dict[str, list[str]] = field(default_factory=dict)
31
41
 
32
42
  # ---------------------------------------------------------------------------
33
43
  # Queries
34
44
  # ---------------------------------------------------------------------------
35
45
 
36
46
  def implementations_of(self, interface_fqn: str) -> list[str]:
37
- """Return FQNs of classes that implement interface_fqn (in-repo only)."""
47
+ """Return FQNs of classes that implement interface_fqn (in-repo only).
48
+
49
+ Strictly `implements` edges — excludes sub-interfaces/subclasses. Use
50
+ subtypes_of()/all_subtypes_of() for the full impact-relevant descendant set.
51
+ """
38
52
  return self._impl_of.get(interface_fqn, [])
39
53
 
40
54
  def interfaces_of(self, class_fqn: str) -> list[str]:
41
55
  """Return FQNs of in-repo interfaces implemented by class_fqn."""
42
56
  return self._ifaces_of.get(class_fqn, [])
43
57
 
58
+ def subtypes_of(self, type_fqn: str) -> list[str]:
59
+ """Return direct in-repo subtypes of type_fqn.
60
+
61
+ Union of `implements` (concrete impls) and `extends` (sub-interfaces,
62
+ subclasses) children. This is the impact-relevant descendant set: a change
63
+ to type_fqn's contract propagates to all of these.
64
+ """
65
+ return self._subtype_of.get(type_fqn, [])
66
+
67
+ def supertypes_of(self, type_fqn: str) -> list[str]:
68
+ """Return direct in-repo supertypes of type_fqn (implemented/extended)."""
69
+ return self._supertype_of.get(type_fqn, [])
70
+
71
+ def all_subtypes_of(self, type_fqn: str) -> list[str]:
72
+ """Return the transitive closure of in-repo subtypes (BFS, cycle-safe).
73
+
74
+ Covers multi-level hierarchies, e.g. a base interface → sub-interface →
75
+ concrete impl chain. Order is breadth-first from type_fqn; deduplicated.
76
+ """
77
+ seen: set[str] = set()
78
+ out: list[str] = []
79
+ queue: list[str] = list(self._subtype_of.get(type_fqn, []))
80
+ while queue:
81
+ sub = queue.pop(0)
82
+ if sub in seen:
83
+ continue
84
+ seen.add(sub)
85
+ out.append(sub)
86
+ queue.extend(self._subtype_of.get(sub, []))
87
+ return out
88
+
44
89
  def primary_implementation(self, interface_fqn: str) -> str | None:
45
90
  """Return the single implementation if unambiguous, else None.
46
91
 
@@ -89,9 +134,14 @@ class ImplementationGraph:
89
134
 
90
135
  impl_of: dict[str, list[str]] = {}
91
136
  ifaces_of: dict[str, list[str]] = {}
137
+ subtype_of: dict[str, list[str]] = {}
138
+ supertype_of: dict[str, list[str]] = {}
92
139
 
93
140
  for edge in dependencies:
94
- if edge.get("type") != "implements":
141
+ etype = edge.get("type")
142
+ # CH-001c: extends edges (sub-interface / subclass) are subtype relations
143
+ # too, even though they never feed the implements-only DI indices.
144
+ if etype not in ("implements", "extends"):
95
145
  continue
96
146
  from_fqn = (edge.get("from") or "").strip()
97
147
  to_fqn = (edge.get("to") or "").strip()
@@ -110,12 +160,27 @@ class ImplementationGraph:
110
160
  if ">" in from_fqn or "<" in from_fqn:
111
161
  continue
112
162
 
113
- if from_fqn not in impl_of.get(to_fqn, []):
114
- impl_of.setdefault(to_fqn, []).append(from_fqn)
115
- if to_fqn not in ifaces_of.get(from_fqn, []):
116
- ifaces_of.setdefault(from_fqn, []).append(to_fqn)
163
+ # Subtype indices both implements and extends contribute descendants.
164
+ if from_fqn not in subtype_of.get(to_fqn, []):
165
+ subtype_of.setdefault(to_fqn, []).append(from_fqn)
166
+ if to_fqn not in supertype_of.get(from_fqn, []):
167
+ supertype_of.setdefault(from_fqn, []).append(to_fqn)
168
+
169
+ # Implements-only indices — preserve DI resolution semantics. Sub-interfaces
170
+ # and subclasses (extends) must NOT count as "implementations" for
171
+ # primary_implementation() bean resolution.
172
+ if etype == "implements":
173
+ if from_fqn not in impl_of.get(to_fqn, []):
174
+ impl_of.setdefault(to_fqn, []).append(from_fqn)
175
+ if to_fqn not in ifaces_of.get(from_fqn, []):
176
+ ifaces_of.setdefault(from_fqn, []).append(to_fqn)
117
177
 
118
- return cls(_impl_of=impl_of, _ifaces_of=ifaces_of)
178
+ return cls(
179
+ _impl_of=impl_of,
180
+ _ifaces_of=ifaces_of,
181
+ _subtype_of=subtype_of,
182
+ _supertype_of=supertype_of,
183
+ )
119
184
 
120
185
 
121
186
  # ---------------------------------------------------------------------------
sourcecode/cli.py CHANGED
@@ -230,6 +230,8 @@ _SUBCOMMANDS: frozenset[str] = frozenset(
230
230
  "cold-start",
231
231
  # Spring semantic audit
232
232
  "spring-audit",
233
+ # Request-body validation surface
234
+ "validation",
233
235
  # Spring impact chain
234
236
  "impact-chain",
235
237
  # PR blast-radius report
@@ -3915,6 +3917,105 @@ def endpoints_cmd(
3915
3917
  _nudge()
3916
3918
 
3917
3919
 
3920
+ @app.command("validation")
3921
+ def validation_cmd(
3922
+ path: Path = typer.Argument(
3923
+ Path("."),
3924
+ help="Repository path to scan for request-body validation (default: current directory)",
3925
+ ),
3926
+ output_path: Optional[Path] = typer.Option(
3927
+ None, "--output", "-o",
3928
+ help="Write output to a file instead of stdout.",
3929
+ ),
3930
+ format: str = typer.Option(
3931
+ "json",
3932
+ "--format",
3933
+ "-f",
3934
+ help="Output format: json (default) or yaml.",
3935
+ show_default=True,
3936
+ ),
3937
+ copy: bool = typer.Option(
3938
+ False,
3939
+ "--copy",
3940
+ "-c",
3941
+ help="Copy output to system clipboard after a successful run.",
3942
+ ),
3943
+ path_prefix: Optional[str] = typer.Option(
3944
+ None, "--path-prefix", "-p",
3945
+ help="Filter endpoints whose URL path starts with this prefix.",
3946
+ ),
3947
+ gaps_only: bool = typer.Option(
3948
+ False, "--gaps-only",
3949
+ help="Report only endpoints/fields with no declared validation (the gaps section).",
3950
+ ),
3951
+ ) -> None:
3952
+ """Map request-body validation per endpoint (constraints + custom validators).
3953
+
3954
+ \b
3955
+ Aggregates two sources of bean-validation truth so an agent knows exactly
3956
+ what a request body must satisfy before touching it:
3957
+ * declarative constraints on the DTOs (@Pattern/@Size/@NotNull, min/max,
3958
+ enum), recovered from the OpenAPI spec even when the DTOs are generated
3959
+ under target/generated-sources (not scanned);
3960
+ * hand-written custom validators (@Constraint + ConstraintValidator, e.g.
3961
+ PetAgeValidator), linked to fields via x-field-extra-annotation.
3962
+
3963
+ \b
3964
+ Output (JSON): per-endpoint validatedFields with their rules + custom
3965
+ validators, the discovered custom-validator catalog, and the set of body
3966
+ endpoints with no declared validation (gaps).
3967
+
3968
+ \b
3969
+ Examples:
3970
+ sourcecode validation .
3971
+ sourcecode validation . --gaps-only
3972
+ sourcecode validation . --path-prefix /owners
3973
+ sourcecode validation . --format yaml
3974
+ """
3975
+ _enforce_format("validation", format)
3976
+
3977
+ target = path.resolve()
3978
+ if not target.exists() or not target.is_dir():
3979
+ _emit_error_json(
3980
+ INVALID_INPUT_CODE,
3981
+ f"'{target}' is not a valid directory.",
3982
+ path=str(target),
3983
+ hint="Pass an existing repository directory.",
3984
+ expected="A directory path.",
3985
+ )
3986
+ raise typer.Exit(code=1)
3987
+
3988
+ from sourcecode.validation_surface import build_validation_surface
3989
+ data = build_validation_surface(target)
3990
+
3991
+ if path_prefix:
3992
+ data["endpoints"] = [
3993
+ e for e in data.get("endpoints", [])
3994
+ if str(e.get("path", "")).startswith(path_prefix)
3995
+ ]
3996
+ data["gaps"] = [
3997
+ g for g in data.get("gaps", [])
3998
+ if str(g.get("path", "")).startswith(path_prefix)
3999
+ ]
4000
+ if gaps_only:
4001
+ data = {
4002
+ "gaps": data.get("gaps", []),
4003
+ "summary": data.get("summary", {}),
4004
+ }
4005
+
4006
+ output = _serialize_dict(data, format)
4007
+ _summary = data.get("summary", {})
4008
+ _emit_command_output(
4009
+ output, output_path, copy,
4010
+ success_msg=f"Validation surface written to {output_path} "
4011
+ f"({_summary.get('endpoints_with_body', 0)} body endpoints, "
4012
+ f"{_summary.get('gaps', 0)} gaps)",
4013
+ )
4014
+
4015
+ from sourcecode.mcp_nudge import nudge_mcp_if_needed as _nudge
4016
+ _nudge()
4017
+
4018
+
3918
4019
  # ── Spring Semantic Audit ─────────────────────────────────────────────────────
3919
4020
 
3920
4021
 
@@ -27,6 +27,7 @@ FORMAT_REGISTRY: "dict[str, tuple[str, ...]]" = {
27
27
  "repo-ir": ("json", "yaml"),
28
28
  "impact": ("json", "yaml"),
29
29
  "endpoints": ("json", "yaml"),
30
+ "validation": ("json", "yaml"),
30
31
  "impact-chain": ("json", "yaml"),
31
32
  "pr-impact": ("text", "json"),
32
33
  "migrate-check": ("json", "text"),
@@ -864,6 +864,31 @@ Returns: endpoints list with method, path, controller, handler fields;
864
864
  "unknown" (no security signals detected).
865
865
  Supports Spring MVC (@GetMapping etc.) and JAX-RS (@GET/@POST etc.).
866
866
  repo_path: absolute path to the Java repository (default: current working directory).
867
+ """
868
+
869
+ _GET_VALIDATION_DOC = """\
870
+ Request-body validation surface per endpoint. JAVA/SPRING ONLY.
871
+
872
+ Do NOT call this on non-Java repositories — it will return empty results.
873
+
874
+ Combines two sources of bean-validation truth so you know what a request body
875
+ must satisfy before generating a payload, a test, or reasoning about a 400:
876
+ * declarative constraints on the DTOs (@Pattern/@Size/@NotNull, minimum/maximum,
877
+ enum) — recovered from the OpenAPI spec even when DTOs are generated under
878
+ target/generated-sources (not scanned);
879
+ * hand-written custom validators (@Constraint + ConstraintValidator, e.g.
880
+ PetAgeValidator), linked to fields via x-field-extra-annotation.
881
+
882
+ Maps to: sourcecode validation <repo_path>
883
+ Returns: endpoints[] (method, path, controller, handler, schema, validatedFields[
884
+ {name, rules[{kind,value}], customValidators[{annotation,validators,message,resolved}]}]),
885
+ custom_validators[] (catalog: annotation, validators, message, validatedTypes, targets),
886
+ gaps[] (POST/PUT/PATCH endpoints with no declared validation),
887
+ summary, openapi_spec.
888
+ An unresolved custom annotation (referenced in the spec, no validator in source)
889
+ is reported with resolved=false.
890
+ repo_path: absolute path to the Java repository (default: current working directory).
891
+ gaps_only: when true, return only the gaps section (endpoints lacking validation).
867
892
  """
868
893
 
869
894
  _CACHE_STATUS_DOC = """\
@@ -1083,6 +1108,27 @@ repo_path: absolute path to the repository (default: current working directory).
1083
1108
  docstring_override=_GET_ENDPOINTS_DOC,
1084
1109
  ),
1085
1110
 
1111
+ # --- get_validation: clean alias replacing raw canonical (6 CLI params) ---
1112
+ _alias_spec(
1113
+ "get_validation",
1114
+ "Request-body validation surface per endpoint (constraints + custom validators). JAVA/SPRING ONLY.",
1115
+ ("validation",),
1116
+ (
1117
+ ToolParamSpec("repo_path", "argument", str, required=False, default=".", is_path=True),
1118
+ ToolParamSpec("gaps_only", "option", bool, required=False, default=False,
1119
+ option_names=("--gaps-only",), is_flag=True,
1120
+ help="Return only endpoints/fields lacking validation."),
1121
+ ),
1122
+ lambda inputs: (
1123
+ ["validation", str(inputs.get("repo_path", "."))]
1124
+ + (["--gaps-only"] if bool(inputs.get("gaps_only")) else [])
1125
+ ),
1126
+ supported_targets=("repo_path",),
1127
+ unsupported_targets=("file_path",),
1128
+ validator=validate_repo_path,
1129
+ docstring_override=_GET_VALIDATION_DOC,
1130
+ ),
1131
+
1086
1132
  # --- cache management: curated aliases stripping CLI noise params ---
1087
1133
  _alias_spec(
1088
1134
  "cache_status",
@@ -1214,6 +1260,7 @@ _MCP_HIDDEN_CANONICAL_TOOLS: frozenset[str] = frozenset({
1214
1260
  "modernize", # duplicate of modernize_context
1215
1261
  # Raw CLI tools with output-format/noise params — clean alias with only repo_path exists
1216
1262
  "endpoints", # 7 CLI params (output_path/format/copy/etc.); use get_endpoints
1263
+ "validation", # 6 CLI params (output_path/format/copy/path_prefix/gaps_only); use get_validation
1217
1264
  "cache_status", # path + json_output flag; curated alias strips json_output, renames path→repo_path
1218
1265
  "cache_warm", # path + compact/agent output flags; curated alias keeps only repo_path
1219
1266
  "cache_clear", # path + yes/all_ destructive flags; curated alias keeps repo_path + include_ris only
@@ -68,6 +68,9 @@ class FieldConstraint:
68
68
  fmt: Optional[str] = None
69
69
  enum: Optional[list[Any]] = None
70
70
  ref: Optional[str] = None # schema name when the field is an object/array ref
71
+ # Custom bean-validation annotations injected via openapi-generator's
72
+ # ``x-field-extra-annotation`` vendor extension (simple class names).
73
+ extra_annotations: "list[str]" = field(default_factory=list)
71
74
 
72
75
  def to_dict(self) -> "dict[str, Any]":
73
76
  out: "dict[str, Any]" = {"name": self.name, "required": self.required}
@@ -84,6 +87,8 @@ class FieldConstraint:
84
87
  ):
85
88
  if val is not None:
86
89
  out[key] = val
90
+ if self.extra_annotations:
91
+ out["extraAnnotations"] = self.extra_annotations
87
92
  return out
88
93
 
89
94
 
@@ -255,6 +260,32 @@ def _ref_name(ref: Any) -> Optional[str]:
255
260
  return None
256
261
 
257
262
 
263
+ def _extra_annotations(prop: "dict[str, Any]") -> "list[str]":
264
+ """Extract simple class names from ``x-field-extra-annotation``.
265
+
266
+ openapi-generator injects custom bean-validation annotations on generated DTO
267
+ fields via this vendor extension, e.g.
268
+ ``x-field-extra-annotation: "@com.x.PetAgeValidation"`` (string) or a list of
269
+ such entries. We keep the trailing simple name (``PetAgeValidation``).
270
+ """
271
+ import re as _re
272
+
273
+ raw = prop.get("x-field-extra-annotation")
274
+ if raw is None:
275
+ return []
276
+ items = raw if isinstance(raw, list) else [raw]
277
+ names: "list[str]" = []
278
+ for item in items:
279
+ if not isinstance(item, str):
280
+ continue
281
+ # Each entry may carry several annotations; grab every @Name token.
282
+ for m in _re.finditer(r"@\s*([\w.]+)", item):
283
+ simple = m.group(1).rsplit(".", 1)[-1]
284
+ if simple and simple not in names:
285
+ names.append(simple)
286
+ return names
287
+
288
+
258
289
  def _field_from_property(name: str, prop: Any, required: bool) -> FieldConstraint:
259
290
  fc = FieldConstraint(name=name, required=required)
260
291
  if not isinstance(prop, dict):
@@ -276,6 +307,7 @@ def _field_from_property(name: str, prop: Any, required: bool) -> FieldConstrain
276
307
  enum = prop.get("enum")
277
308
  if isinstance(enum, list):
278
309
  fc.enum = list(enum)
310
+ fc.extra_annotations = _extra_annotations(prop)
279
311
  if fc.type is None and prop.get("type") == "array":
280
312
  items = prop.get("items")
281
313
  if isinstance(items, dict):