sourcecode 4.2.0__py3-none-any.whl → 4.3.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.
@@ -0,0 +1,200 @@
1
+ """data_labels.py — what the repository's owner says is sensitive, declared once.
2
+
3
+ Nothing in Java says that `Person.dni` is a national identifier and `Person.id` is
4
+ a surrogate key. A tool that guessed from the name would be guessing (and would be
5
+ guessing in one language, about one naming convention, which is exactly the
6
+ vendor-agnostic rule this product holds itself to: names are evidence, never the
7
+ branch). So the label is **declared**, and everything downstream is measured.
8
+
9
+ The declaration lives beside the other repository-specific semantics the analyzer
10
+ accepts — `sourcecode.config.json`, next to `customSecurityAnnotations`::
11
+
12
+ {
13
+ "dataLabels": [
14
+ {
15
+ "label": "pii",
16
+ "types": ["com.example.domain.Person"],
17
+ "fields": ["com.example.domain.Account#iban"],
18
+ "note": "GDPR art. 4(1) — identifies a natural person"
19
+ }
20
+ ]
21
+ }
22
+
23
+ `types` labels a type outright; `fields` labels one member, and the type that
24
+ declares it becomes a seed too, because a route that returns the type can return
25
+ the member. Both forms accept a fully-qualified name or the simple name as written
26
+ in the source.
27
+
28
+ Best-effort loading, with one difference from `security_config`: a declaration this
29
+ module could not use is **returned as a problem**, never dropped in silence. A label
30
+ that was ignored because of a typo would otherwise read as "nothing sensitive is
31
+ exposed", which is the worst possible false answer for the question being asked.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import json
37
+ from dataclasses import dataclass
38
+ from pathlib import Path
39
+ from typing import Optional
40
+
41
+ #: The same file the custom security annotations are declared in.
42
+ CONFIG_FILENAME = "sourcecode.config.json"
43
+
44
+ #: The key under which labels are declared.
45
+ CONFIG_KEY = "dataLabels"
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class DataLabel:
50
+ """One declared label and the types/members it applies to."""
51
+
52
+ label: str
53
+ types: tuple[str, ...] = ()
54
+ fields: tuple[str, ...] = ()
55
+ note: str = ""
56
+
57
+ @property
58
+ def seed_types(self) -> tuple[str, ...]:
59
+ """Every type this label reaches into: declared types, plus the types that
60
+ declare a labelled field. A member cannot leave a process without the type
61
+ that holds it, so the type is the unit the graph is walked on — stated here
62
+ rather than left implicit, because it is also the reason the answer
63
+ over-reports a route that maps the type and drops the member."""
64
+ owners = [f.split("#", 1)[0].strip() for f in self.fields if "#" in f]
65
+ seen: list[str] = []
66
+ for name in [*self.types, *owners]:
67
+ if name and name not in seen:
68
+ seen.append(name)
69
+ return tuple(seen)
70
+
71
+ def to_dict(self) -> dict:
72
+ return {
73
+ "label": self.label,
74
+ "types": list(self.types),
75
+ "fields": list(self.fields),
76
+ "note": self.note,
77
+ }
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class LabelDeclaration:
82
+ """Everything the configuration said, including what could not be used."""
83
+
84
+ labels: tuple[DataLabel, ...] = ()
85
+ problems: tuple[dict, ...] = ()
86
+ config_file: Optional[str] = None
87
+ declared: bool = False # the key was present, whatever it contained
88
+
89
+ def to_dict(self) -> dict:
90
+ return {
91
+ "config_file": self.config_file,
92
+ "declared": self.declared,
93
+ "labels": [label.to_dict() for label in self.labels],
94
+ "problems": [dict(p) for p in self.problems],
95
+ }
96
+
97
+
98
+ def _problem(where: str, reason: str, detail: str = "") -> dict:
99
+ out = {"declaration": where, "reason": reason}
100
+ if detail:
101
+ out["detail"] = detail
102
+ return out
103
+
104
+
105
+ def load_labels(root: Optional[Path]) -> LabelDeclaration:
106
+ """Read `<root>/sourcecode.config.json` and return the declared labels.
107
+
108
+ Never raises. An absent file is not a problem — it is a repository that has
109
+ declared nothing, and the caller says so in the words of the question.
110
+ """
111
+ if root is None:
112
+ return LabelDeclaration()
113
+ cfg_path = Path(root) / CONFIG_FILENAME
114
+ try:
115
+ if not cfg_path.is_file():
116
+ return LabelDeclaration(config_file=None)
117
+ data = json.loads(cfg_path.read_text(encoding="utf-8"))
118
+ except (OSError, json.JSONDecodeError) as exc:
119
+ return LabelDeclaration(
120
+ config_file=str(cfg_path),
121
+ problems=(_problem(CONFIG_FILENAME, "unreadable", f"{type(exc).__name__}: {exc}"),),
122
+ )
123
+
124
+ if not isinstance(data, dict) or CONFIG_KEY not in data:
125
+ return LabelDeclaration(config_file=str(cfg_path))
126
+
127
+ raw = data.get(CONFIG_KEY)
128
+ if not isinstance(raw, list):
129
+ return LabelDeclaration(
130
+ config_file=str(cfg_path),
131
+ declared=True,
132
+ problems=(
133
+ _problem(
134
+ CONFIG_KEY, "not_a_list",
135
+ f"expected a list of label declarations, found {type(raw).__name__}",
136
+ ),
137
+ ),
138
+ )
139
+
140
+ labels: list[DataLabel] = []
141
+ problems: list[dict] = []
142
+ for index, item in enumerate(raw):
143
+ where = f"{CONFIG_KEY}[{index}]"
144
+ if not isinstance(item, dict):
145
+ problems.append(_problem(where, "not_an_object"))
146
+ continue
147
+ name = str(item.get("label") or "").strip()
148
+ if not name:
149
+ problems.append(_problem(where, "no_label", "every declaration needs a `label`"))
150
+ continue
151
+ types = _string_list(item.get("types"), where, "types", problems)
152
+ fields = _string_list(item.get("fields"), where, "fields", problems)
153
+ bad_fields = [f for f in fields if "#" not in f]
154
+ fields = tuple(f for f in fields if "#" in f)
155
+ for bad in bad_fields:
156
+ problems.append(
157
+ _problem(
158
+ f"{where}.fields", "not_a_member_reference",
159
+ f"{bad!r}: a field is written Type#member — a bare type belongs in `types`",
160
+ )
161
+ )
162
+ if not types and not fields:
163
+ problems.append(
164
+ _problem(where, "nothing_labelled", f"{name!r} names neither a type nor a field")
165
+ )
166
+ continue
167
+ labels.append(
168
+ DataLabel(
169
+ label=name, types=types, fields=fields,
170
+ note=str(item.get("note") or "").strip(),
171
+ )
172
+ )
173
+
174
+ return LabelDeclaration(
175
+ labels=tuple(labels), problems=tuple(problems),
176
+ config_file=str(cfg_path), declared=True,
177
+ )
178
+
179
+
180
+ def _string_list(
181
+ value: object, where: str, key: str, problems: list[dict]
182
+ ) -> tuple[str, ...]:
183
+ if value is None:
184
+ return ()
185
+ if isinstance(value, str):
186
+ value = [value]
187
+ if not isinstance(value, list):
188
+ problems.append(
189
+ _problem(f"{where}.{key}", "not_a_list", f"found {type(value).__name__}")
190
+ )
191
+ return ()
192
+ out: list[str] = []
193
+ for entry in value:
194
+ name = str(entry).strip() if isinstance(entry, (str, int)) else ""
195
+ if not name:
196
+ problems.append(_problem(f"{where}.{key}", "empty_entry"))
197
+ continue
198
+ if name not in out:
199
+ out.append(name)
200
+ return tuple(out)
@@ -40,15 +40,22 @@ def dynamic_argument_surface(
40
40
  ) -> tuple[DynamicArgument, ...]:
41
41
  """Every call-site argument of `shape` across the repository, ordered by
42
42
  (owner, occurrence, arg_index). Default "concat" = arguments assembled by
43
- string/additive concatenation. Pure `ContextGraph` query — reads no source."""
44
- owners = graph.symbols(kind="method") + graph.symbols(kind="constructor")
43
+ string/additive concatenation. Pure `ContextGraph` query — reads no source.
44
+
45
+ The owner population is the body-fact layer's own, never a selection of symbol
46
+ kinds. This surface used to enumerate `kind="method"` plus `kind="constructor"`
47
+ nodes, and a request handler is a `kind="endpoint"` node: **every controller body
48
+ in every repository was outside the surface**, silently and with no blind-spot
49
+ note, which is precisely where a query assembled from request-scoped values
50
+ lives. Bodies are keyed by owner, so owners are read from the bodies.
51
+ """
45
52
  out: list[DynamicArgument] = []
46
- for s in sorted(owners, key=lambda n: n.fqn):
47
- for inv in graph.invocations_in(s.fqn):
53
+ for owner in graph.body_owners():
54
+ for inv in graph.invocations_in(owner):
48
55
  for idx, sh in enumerate(inv.arg_shapes):
49
56
  if sh == shape:
50
57
  out.append(DynamicArgument(
51
- owner_fqn=s.fqn, call=inv.callee, arg_index=idx,
58
+ owner_fqn=owner, call=inv.callee, arg_index=idx,
52
59
  shape=sh, occurrence=inv.occurrence,
53
60
  ))
54
61
  return tuple(out)
@@ -44,6 +44,7 @@ FORMAT_REGISTRY: "dict[str, tuple[str, ...]]" = {
44
44
  "impact-chain": ("json", "yaml"),
45
45
  "pr-impact": ("json", "text"),
46
46
  "migrate-check": ("json", "text"),
47
+ "migrate-recipe": ("json", "yaml"),
47
48
  "spring-audit": ("json", "yaml", "github-comment"),
48
49
  "explain": ("text", "json"),
49
50
  "prepare-context": ("json", "github-comment"),
@@ -179,6 +179,51 @@ NON_COVERAGE: tuple[NonCoverage, ...] = (
179
179
  "here listed."
180
180
  ),
181
181
  ),
182
+ NonCoverage(
183
+ id="NC-008",
184
+ surface="data_exposure",
185
+ not_covered=(
186
+ "Whether a labelled member actually leaves the process on a given "
187
+ "route — field-level dataflow through mappers, DTOs, serializers and "
188
+ "projections."
189
+ ),
190
+ why=(
191
+ "Exposure here is measured at TYPE granularity over the same call "
192
+ "reach every other command walks: a route reaches the labelled type, "
193
+ "or its signature names it. Whether the mapping in between keeps the "
194
+ "labelled member is a dataflow question, and answering it by "
195
+ "approximation would put a confident `no` on a route that does leak. "
196
+ "So `call_reach` is published as a supremum and says so."
197
+ ),
198
+ instead=(
199
+ "Read the mapping on the routes this answer ranks first — they are the "
200
+ "ones worth a human — and label the DTO too if it carries the member, "
201
+ "which makes the route `signature` instead of `call_reach`."
202
+ ),
203
+ ),
204
+ NonCoverage(
205
+ id="NC-009",
206
+ surface="client_usage",
207
+ not_covered=(
208
+ "Whether a route is called from outside this repository — another "
209
+ "service, a mobile app, a partner, a cron job, or a client whose URL "
210
+ "is assembled at run time."
211
+ ),
212
+ why=(
213
+ "The join reads the TypeScript/JavaScript checked in beside the server "
214
+ "and only the call shapes whose verb and path are written at the call "
215
+ "site. That is a floor on who calls a route and can never be a ceiling: "
216
+ "the repository cannot observe consumers it does not contain. So a route "
217
+ "with no call site here is published as a candidate for deletion, never "
218
+ "as dead API."
219
+ ),
220
+ instead=(
221
+ "Access logs or an API gateway answer who really calls a route. The "
222
+ "opposite direction is decisive without them: a client call matching no "
223
+ "route is a request this repository's own front end makes and its server "
224
+ "does not serve."
225
+ ),
226
+ ),
182
227
  )
183
228
 
184
229
 
@@ -0,0 +1,278 @@
1
+ """openrewrite_recipe.py — the report, in the form the executor takes.
2
+
3
+ Field evaluation #4, on a repository with 585 blockers and an estimated 357
4
+ person-days: *"con 585 bloqueantes jakarta y 357 días estimados, el informe muere en
5
+ un cajón; el codemod se convierte en PRs."* The findings already name the recipe that
6
+ fixes each one — 296 of 309 on openmrs-core, 144 of 170 on BroadleafCommerce — and a
7
+ reader still had to copy them out of a JSON payload by hand into a file OpenRewrite
8
+ can run.
9
+
10
+ So this module emits that file. **It does not rewrite anything**, and that is a
11
+ position, not a gap (`NC-005`): OpenRewrite is the executor this product recommends,
12
+ and a second, worse migration engine is the one way to lose to it. What is built here
13
+ is its *input* — a declarative composite recipe naming exactly the recipes this
14
+ repository's findings named, plus the invocation that runs it.
15
+
16
+ Three rules the output holds itself to:
17
+
18
+ * **Only recipes a finding named.** The recipe list is derived from the report, never
19
+ from a catalogue of what OpenRewrite can do. A recipe nobody's finding asked for
20
+ would rewrite code nobody measured.
21
+ * **The remainder is published beside it.** Every blocking finding with no recipe is
22
+ listed as manual work, because "run this and you are done" is the failure mode a
23
+ 585-blocker report cannot afford twice.
24
+ * **Artifact coordinates are derived from the recipe's own package**, and a recipe
25
+ whose package maps to no known artifact is reported rather than silently dropped —
26
+ an invocation missing one coordinate fails at the executor with a message nobody
27
+ can act on.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from dataclasses import dataclass
33
+ from pathlib import Path
34
+ from typing import Any, Optional
35
+
36
+ SCHEMA_VERSION = "openrewrite-plan-v1"
37
+
38
+ #: Default name of the emitted recipe, and of the file it is written to.
39
+ DEFAULT_RECIPE_NAME = "ask.MigrationFromMeasuredFindings"
40
+ DEFAULT_FILENAME = "rewrite.yml"
41
+
42
+ #: Recipe package prefix → the artifact that publishes it. Derived from the recipe id
43
+ #: itself, because that is the only thing the finding carries; a prefix that is not in
44
+ #: this table is published under `unknown_artifacts` rather than guessed, since a
45
+ #: missing coordinate makes the whole invocation fail at the executor.
46
+ _ARTIFACT_BY_PREFIX: "tuple[tuple[str, str], ...]" = (
47
+ ("org.openrewrite.java.migrate.", "org.openrewrite.recipe:rewrite-migrate-java:LATEST"),
48
+ ("org.openrewrite.java.spring.", "org.openrewrite.recipe:rewrite-spring:LATEST"),
49
+ ("org.openrewrite.java.testing.", "org.openrewrite.recipe:rewrite-testing-frameworks:LATEST"),
50
+ ("org.openrewrite.hibernate.", "org.openrewrite.recipe:rewrite-hibernate:LATEST"),
51
+ ("org.openrewrite.maven.", ""), # core, shipped with the plugin
52
+ ("org.openrewrite.java.", ""), # core java recipes
53
+ )
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class RecipePlan:
58
+ """A composite recipe built from measured findings, and how to run it."""
59
+
60
+ recipe_name: str
61
+ recipes: tuple[str, ...]
62
+ artifacts: tuple[str, ...]
63
+ unknown_artifacts: tuple[str, ...]
64
+ covered_findings: int
65
+ total_findings: int
66
+ report_blocking_count: int
67
+ manual_findings: tuple[dict, ...]
68
+ population: str
69
+ build_tool: str
70
+ document: str
71
+ invocation: str
72
+
73
+ @property
74
+ def statement(self) -> str:
75
+ if not self.recipes:
76
+ return (
77
+ f"None of the {self.total_findings} findings in this population carries "
78
+ "an OpenRewrite recipe, so there is no recipe to write and nothing to "
79
+ "run. All of it is manual work, listed under `manual_remainder` — which "
80
+ "is an answer, not an empty result."
81
+ )
82
+ return (
83
+ f"{self.covered_findings} of {self.total_findings} findings name a recipe; "
84
+ f"this document runs those and nothing else. The {len(self.manual_findings)} "
85
+ "left are listed under `manual_remainder` and no recipe applies to them. "
86
+ "Nothing here edits source: the executor does, and only when you run the "
87
+ "invocation."
88
+ )
89
+
90
+ def to_dict(self) -> dict:
91
+ return {
92
+ "schema_version": SCHEMA_VERSION,
93
+ "recipe_name": self.recipe_name,
94
+ "recipes": list(self.recipes),
95
+ "recipe_artifacts": list(self.artifacts),
96
+ "unknown_recipe_artifacts": list(self.unknown_artifacts),
97
+ "coverage": {
98
+ "findings_with_a_recipe": self.covered_findings,
99
+ "findings_considered": self.total_findings,
100
+ "findings_left_manual": len(self.manual_findings),
101
+ "population": self.population,
102
+ # Published beside it because it is the figure `migrate-check`
103
+ # headlines, and it counts something narrower: two populations under
104
+ # one name is the defect this project keeps finding in its own output.
105
+ "report_blocking_count": self.report_blocking_count,
106
+ "report_blocking_count_unit": (
107
+ "critical + high findings in product code — `migrate-check`'s "
108
+ "headline figure, a subset of the population above"
109
+ ),
110
+ "basis": (
111
+ "counted over the population named above, from the same "
112
+ "`openrewrite_recipe` field `migrate-check` publishes per finding"
113
+ ),
114
+ },
115
+ "manual_remainder": [dict(row) for row in self.manual_findings],
116
+ "build_tool": self.build_tool,
117
+ "document": self.document,
118
+ "filename": DEFAULT_FILENAME,
119
+ "invocation": self.invocation,
120
+ "statement": self.statement,
121
+ }
122
+
123
+
124
+ def detect_build_tool(root: Path) -> str:
125
+ """`maven` | `gradle` | `unknown`, from what the repository actually declares."""
126
+ root = Path(root)
127
+ if (root / "pom.xml").is_file():
128
+ return "maven"
129
+ for name in ("build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts"):
130
+ if (root / name).is_file():
131
+ return "gradle"
132
+ return "unknown"
133
+
134
+
135
+ def artifact_for(recipe: str) -> Optional[str]:
136
+ """The artifact coordinate publishing `recipe`, "" for core, None when unknown."""
137
+ for prefix, artifact in _ARTIFACT_BY_PREFIX:
138
+ if recipe.startswith(prefix):
139
+ return artifact
140
+ return None
141
+
142
+
143
+ def _yaml_document(recipe_name: str, recipes: "tuple[str, ...]", *, covered: int,
144
+ total: int, manual: int) -> str:
145
+ lines = [
146
+ "# Generated by `ask migrate-recipe` from measured findings — do not hand-edit;",
147
+ "# re-run the command after the code changes and the list changes with it.",
148
+ f"# {covered} of {total} findings name a recipe. The other {manual} are",
149
+ "# manual work and are listed in the command's `manual_remainder`.",
150
+ "---",
151
+ "type: specs.openrewrite.org/v1beta/recipe",
152
+ f"name: {recipe_name}",
153
+ "displayName: Migration recipes named by measured findings",
154
+ "description: >",
155
+ " Every recipe in this list was named by a finding in this repository. It is not",
156
+ " a catalogue of what OpenRewrite can do: a recipe nobody's finding asked for",
157
+ " would rewrite code nobody measured.",
158
+ "recipeList:",
159
+ ]
160
+ lines += [f" - {recipe}" for recipe in recipes]
161
+ return "\n".join(lines) + "\n"
162
+
163
+
164
+ def _invocation(build_tool: str, recipe_name: str, artifacts: "tuple[str, ...]",
165
+ *, recipe_count: int = 1) -> str:
166
+ if recipe_count == 0:
167
+ # Measured on BroadleafCommerce: 25 blocking findings in product code and not
168
+ # one of them carries a recipe. Printing a runnable command for an empty
169
+ # recipe list would hand the reader a green executor run that changes
170
+ # nothing and reads as "the migration is automated".
171
+ return (
172
+ "# Nothing to run: no finding in the population carries an OpenRewrite "
173
+ "recipe, so there is no recipe to execute. The work is in "
174
+ "`manual_remainder`."
175
+ )
176
+ coordinates = ",".join(a for a in artifacts if a)
177
+ if build_tool == "maven":
178
+ cmd = (
179
+ "mvn -U org.openrewrite.maven:rewrite-maven-plugin:run "
180
+ f"-Drewrite.activeRecipes={recipe_name}"
181
+ )
182
+ if coordinates:
183
+ cmd += f" -Drewrite.recipeArtifactCoordinates={coordinates}"
184
+ return cmd
185
+ if build_tool == "gradle":
186
+ return (
187
+ "# add to build.gradle: plugins { id(\"org.openrewrite.rewrite\") }\n"
188
+ f"# rewrite {{ activeRecipe(\"{recipe_name}\") }}\n"
189
+ + ("# dependencies { rewrite(\"" + "\"); rewrite(\"".join(
190
+ a for a in artifacts if a) + "\") }\n" if coordinates else "")
191
+ + "gradle rewriteRun"
192
+ )
193
+ return (
194
+ "# No Maven or Gradle build was found at this path, so the invocation cannot "
195
+ "be written for you. Run the recipe with the OpenRewrite CLI or add the "
196
+ "plugin to whatever builds this repository."
197
+ )
198
+
199
+
200
+ def build_plan(
201
+ report: Any,
202
+ root: Path,
203
+ *,
204
+ recipe_name: str = DEFAULT_RECIPE_NAME,
205
+ include_hygiene: bool = False,
206
+ ) -> RecipePlan:
207
+ """Turn a `MigrationReport` into the recipe document its findings named.
208
+
209
+ The population is the **blocking** findings by default, not every main-context
210
+ one. Measured on openmrs-core: the wider set pulls in `JavaTimeAPIs`, a hygiene
211
+ recipe that rewrites `java.util.Date` across the repository — real work, and not
212
+ work anybody asked for while migrating to Boot 3. A migration recipe that also
213
+ modernises date handling is a diff nobody can review as one thing.
214
+ `--include-hygiene` opts into it, exactly as `migrate-check` does.
215
+ """
216
+ from sourcecode.migrate_check import _BEST_PRACTICE_TARGETS
217
+
218
+ findings = [
219
+ f for f in getattr(report, "findings", []) or []
220
+ if getattr(f, "code_context", "main") == "main"
221
+ and (include_hygiene
222
+ or getattr(f, "migration_target", "") not in _BEST_PRACTICE_TARGETS)
223
+ ]
224
+ recipes = sorted({
225
+ str(f.openrewrite_recipe) for f in findings
226
+ if getattr(f, "openrewrite_recipe", None)
227
+ })
228
+ manual = [
229
+ {
230
+ "rule_id": getattr(f, "rule_id", ""),
231
+ "severity": getattr(f, "severity", ""),
232
+ "title": getattr(f, "title", ""),
233
+ "source_file": getattr(f, "source_file", ""),
234
+ "first_line": getattr(f, "first_line", 0),
235
+ "fix_hint": getattr(f, "fix_hint", ""),
236
+ }
237
+ for f in findings
238
+ if not getattr(f, "openrewrite_recipe", None)
239
+ ]
240
+ artifacts: list[str] = []
241
+ unknown: list[str] = []
242
+ for recipe in recipes:
243
+ artifact = artifact_for(recipe)
244
+ if artifact is None:
245
+ unknown.append(recipe)
246
+ elif artifact and artifact not in artifacts:
247
+ artifacts.append(artifact)
248
+
249
+ covered = len(findings) - len(manual)
250
+ build_tool = detect_build_tool(root)
251
+ population = (
252
+ "findings in product code at or above the severity floor of this run, "
253
+ "advisory hygiene included (--include-hygiene)"
254
+ if include_hygiene else
255
+ "findings in product code at or above the severity floor of this run, with "
256
+ "the advisory hygiene bucket excluded — a migration diff that also modernises "
257
+ "date handling cannot be reviewed as one change"
258
+ )
259
+ return RecipePlan(
260
+ recipe_name=recipe_name,
261
+ recipes=tuple(recipes),
262
+ artifacts=tuple(sorted(artifacts)),
263
+ unknown_artifacts=tuple(unknown),
264
+ covered_findings=covered,
265
+ total_findings=len(findings),
266
+ manual_findings=tuple(manual),
267
+ report_blocking_count=int(getattr(report, "blocking_count", 0) or 0),
268
+ population=population,
269
+ build_tool=build_tool,
270
+ document=_yaml_document(
271
+ recipe_name, tuple(recipes),
272
+ covered=covered, total=len(findings), manual=len(manual),
273
+ ),
274
+ invocation=_invocation(
275
+ build_tool, recipe_name, tuple(sorted(artifacts)),
276
+ recipe_count=len(recipes),
277
+ ),
278
+ )
sourcecode/posture.py CHANGED
@@ -883,6 +883,38 @@ _OPEN_BY_RULE = frozenset({"permit_all", "anonymous"})
883
883
  _UNDECIDED_ACCESS = frozenset({"no_rule_matched", "undecided"})
884
884
 
885
885
 
886
+ def endpoint_access_under(
887
+ root: Path,
888
+ profiles: "Optional[set[str]]" = None,
889
+ *,
890
+ cir: "Optional[Any]" = None,
891
+ properties: "Optional[dict[str, str]]" = None,
892
+ ) -> "dict[str, str]":
893
+ """`endpoint_id` → the access decision this profile set produces.
894
+
895
+ The per-endpoint half of `build_posture`, published for the consumers that
896
+ need the decision rather than the report — `ask risk` and `ask enrich` weigh
897
+ it as their access axis. One producer for the resolution, so a composed row
898
+ and a `posture` run can never disagree about the same profile set.
899
+ """
900
+ return _posture(root, profiles, properties, cir=cir)[1]
901
+
902
+
903
+ def access_verdict(decision: str) -> str:
904
+ """A profile-resolved decision, in the vocabulary the risk composition weighs.
905
+
906
+ `no_rule_matched` is **not** open: no rule this analyzer could read matched the
907
+ request, which is unknown coverage. Folding the two together is the confident
908
+ security falsehood `posture` exists to prevent, and this function is where that
909
+ distinction is kept for every consumer at once.
910
+ """
911
+ if decision in _OPEN_BY_RULE:
912
+ return "open"
913
+ if decision in _UNDECIDED_ACCESS:
914
+ return "unknown"
915
+ return "protected"
916
+
917
+
886
918
  def resolve_environments(
887
919
  root: Path,
888
920
  properties: "Optional[dict[str, str]]" = None,
@@ -1243,6 +1243,17 @@ def _extract_symbols(
1243
1243
  # from the graph instead of re-reading controller source.
1244
1244
  if "@Valid" in _params_str or "@Validated" in _params_str:
1245
1245
  pending_ann_values["_validated_params"] = _params_str.strip()
1246
+ # The handler's own parameter list, captured for request
1247
+ # handlers only. `validation_inference` approximated the
1248
+ # body surface by the verb — POST/PUT/PATCH — and declared
1249
+ # that "no IR atom exposes" the parameters, so a handler
1250
+ # taking no arguments at all counted as a body endpoint
1251
+ # accepting an unvalidated body. An empty string is the
1252
+ # answer "no parameters"; the key is absent when the list
1253
+ # spans lines and was not resolved, which is not the same
1254
+ # thing and must not be read as one.
1255
+ if any(a in _ENDPOINT_ANNOTATIONS for a in pending_anns):
1256
+ pending_ann_values["_handler_params"] = _params_str.strip()
1246
1257
  # Phase 3.2: annotation-member default. In Java grammar a
1247
1258
  # post-paren `default X;` exists ONLY on @interface members
1248
1259
  # (`String message() default "...";`) — interface default
@@ -2267,6 +2278,16 @@ def _type_tokens(expr: str) -> tuple[Optional[str], list[str]]:
2267
2278
  return outer, args
2268
2279
 
2269
2280
 
2281
+ #: Symbol kinds whose DECLARATION carries types — the class-scope counterpart of
2282
+ #: `_BODY_OWNER_KINDS`. A request handler is classified `endpoint` and a factory
2283
+ #: method `bean` at emission, never `method`, so a surface that enumerated methods
2284
+ #: alone could not see the signature of a single controller handler in the
2285
+ #: repository: `structural_references_to_type("Customer")` returned services and
2286
+ #: entities and no route at all, which reads as "no endpoint accepts or returns
2287
+ #: this type". Same shape as the F.4-1 body-surface seam, one scope up.
2288
+ _SIGNATURE_MEMBER_KINDS: "frozenset[str]" = frozenset({"method", "endpoint", "bean"})
2289
+
2290
+
2270
2291
  def _extract_class_type_refs(symbols: list[SymbolRecord]) -> dict[str, list[dict]]:
2271
2292
  """Class-structure TypeReference atoms per class FQN (Semantic IR P-A, class
2272
2293
  scope). Pure projection over already-extracted symbols — reads NO source, adds
@@ -2276,15 +2297,15 @@ def _extract_class_type_refs(symbols: list[SymbolRecord]) -> dict[str, list[dict
2276
2297
  by_class: dict[str, list[tuple[str, str, str, str]]] = {} # cls -> (member, kind, context, type)
2277
2298
  for s in symbols:
2278
2299
  kind = s.symbol_kind
2279
- if kind == "method":
2300
+ if kind in _SIGNATURE_MEMBER_KINDS:
2280
2301
  cls = _enclosing_class(s.symbol)
2281
2302
  ro, _ = _type_tokens(s.return_type)
2282
2303
  if ro and ro != "void":
2283
- by_class.setdefault(cls, []).append((s.symbol, "method", "return_type", ro))
2304
+ by_class.setdefault(cls, []).append((s.symbol, kind, "return_type", ro))
2284
2305
  for p in s.param_types:
2285
2306
  po, _ = _type_tokens(p)
2286
2307
  if po:
2287
- by_class.setdefault(cls, []).append((s.symbol, "method", "param_type", po))
2308
+ by_class.setdefault(cls, []).append((s.symbol, kind, "param_type", po))
2288
2309
  elif kind == "constructor":
2289
2310
  cls = _enclosing_class(s.symbol)
2290
2311
  for p in s.param_types: