sourcecode 5.2.0__py3-none-any.whl → 5.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,357 @@
1
+ """consumer_join.py — the consumers live in other repositories (F-BH).
2
+
3
+ One repository is analysed; the system is four. `client_calls` already joins a
4
+ client checked in *beside* the server, and the field's shape is the ordinary one:
5
+ a server repository, and its consumers in separate repositories nobody analyses
6
+ together. So the endpoint census answers *what is exposed* and the question that
7
+ decides the work — *what does anybody call* — has no answer, and every reduction
8
+ plan starts with "protect 2 635 routes".
9
+
10
+ Given the consumers, the plan inverts: of 2 635 routes a readable rule leaves
11
+ open, the ones a known consumer calls are **fix now**, and the ones nobody calls
12
+ are **delete, do not protect** — the cheapest reduction of exposure available on
13
+ a large surface, and one no single-repository analysis can propose.
14
+
15
+ Three rules, and the first is the one that makes the output safe to act on:
16
+
17
+ * **An uncalled route is a candidate, never a proven-dead one.** What was read is
18
+ the consumers the caller named, in the call shapes whose verb and path are
19
+ written at the call site. A route absent from that is absent from *what was
20
+ read*; the payload publishes the direction (`under_reports`) and the
21
+ per-consumer coverage that bounds it.
22
+ * **A client call matching no route is the decisive direction.** It is a request
23
+ a consumer makes that this server does not serve — a broken integration or a
24
+ route that was deleted — and it needs no assumption about coverage to be true.
25
+ * **Route-variable normalisation is confidence, not identity.** `/{bankId}` and
26
+ `${bank-id}` are the same position with two spellings, so the join key
27
+ neutralises variable segments (`client_calls.normalize_path`) and both original
28
+ spellings travel on the row. A match through a normalised segment is reported
29
+ as `normalised`, never as an exact one.
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import re
34
+ from pathlib import Path
35
+ from typing import Any, Iterable, Optional
36
+
37
+ from sourcecode.client_calls import (
38
+ ClientCall,
39
+ _iter_client_files,
40
+ _path_from_url,
41
+ extract_client_calls,
42
+ normalize_path,
43
+ )
44
+
45
+ SCHEMA_VERSION = "consumer-join-v1"
46
+
47
+ #: Java client shapes whose verb and path are written at the call site — the same
48
+ #: admission rule as the TypeScript side, in the language most server-to-server
49
+ #: consumers are written in. A path assembled from a variable is not a call this
50
+ #: module can see, and it is reported unresolved rather than guessed.
51
+ _JAVA_TEMPLATE_CALL = re.compile(
52
+ r"""\b(?P<receiver>[\w$.]*)\.(?P<op>getFor\w+|postFor\w+|put|patch|delete|exchange)\s*\(\s*"""
53
+ r"""(?P<quote>")(?P<url>[^"]*)(?P=quote)""",
54
+ )
55
+ #: `HttpMethod.GET` inside an `exchange(...)` call, which is where the verb lives
56
+ #: when the method name does not carry it.
57
+ _JAVA_EXCHANGE_VERB = re.compile(r"HttpMethod\.(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)")
58
+ #: A declarative HTTP client interface: the verb and path are annotation data.
59
+ _JAVA_ANNOTATION_CALL = re.compile(
60
+ r"""@(?P<ann>GetMapping|PostMapping|PutMapping|PatchMapping|DeleteMapping|RequestMapping|RequestLine)\s*"""
61
+ r"""\(\s*(?P<args>[^)]*)\)""",
62
+ )
63
+ _ANN_VERB = {
64
+ "GetMapping": "GET", "PostMapping": "POST", "PutMapping": "PUT",
65
+ "PatchMapping": "PATCH", "DeleteMapping": "DELETE",
66
+ }
67
+ _STRING_ARG = re.compile(r'"([^"]*)"')
68
+ _REQUEST_LINE = re.compile(r'"\s*(?P<verb>[A-Z]+)\s+(?P<path>/[^"\s]*)')
69
+ _METHOD_ATTR = re.compile(r"RequestMethod\.(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)")
70
+ #: Only a type that declares itself an HTTP client is read this way — an
71
+ #: annotation-shaped path on a server controller is a route, not a call.
72
+ _DECLARATIVE_CLIENT = re.compile(r"@(\w*FeignClient|HttpExchange|RegisterRestClient)\b")
73
+
74
+ _JAVA_SKIP_DIRS = frozenset({
75
+ "target", "build", "out", ".git", "node_modules", ".gradle",
76
+ })
77
+
78
+ _VERB_FROM_OP = {
79
+ "getfor": "GET", "postfor": "POST", "put": "PUT", "patch": "PATCH",
80
+ "delete": "DELETE",
81
+ }
82
+
83
+
84
+ def _java_files(base: Path) -> list[Path]:
85
+ out: list[Path] = []
86
+ for path in base.rglob("*.java"):
87
+ if any(part in _JAVA_SKIP_DIRS for part in path.parts):
88
+ continue
89
+ if path.name.endswith("Test.java") or "/src/test/" in path.as_posix():
90
+ continue
91
+ out.append(path)
92
+ return sorted(out)
93
+
94
+
95
+ def extract_java_client_calls(base: Path) -> "tuple[list[ClientCall], list[dict]]":
96
+ """HTTP calls written in Java client code, with the same admission rule as TS/JS."""
97
+ calls: list[ClientCall] = []
98
+ unresolved: list[dict] = []
99
+ base = Path(base)
100
+ for path in _java_files(base):
101
+ try:
102
+ text = path.read_text(encoding="utf-8", errors="replace")
103
+ except OSError:
104
+ continue
105
+ rel = path.relative_to(base).as_posix()
106
+ for match in _JAVA_TEMPLATE_CALL.finditer(text):
107
+ op = match.group("op").lower()
108
+ verb = next((v for k, v in _VERB_FROM_OP.items() if op.startswith(k)), "")
109
+ if not verb and op == "exchange":
110
+ window = text[match.start(): match.start() + 400]
111
+ verb_match = _JAVA_EXCHANGE_VERB.search(window)
112
+ verb = verb_match.group(1) if verb_match else ""
113
+ route = _path_from_url(match.group("url"))
114
+ line = text.count("\n", 0, match.start()) + 1
115
+ if not verb or route is None:
116
+ unresolved.append({
117
+ "source_file": rel, "line": line,
118
+ "why": (
119
+ "the verb is not written at the call site"
120
+ if not verb else
121
+ "the first argument is not a path this module can resolve"
122
+ ),
123
+ })
124
+ continue
125
+ calls.append(ClientCall(
126
+ method=verb, path=route, source_file=rel, line=line,
127
+ receiver=match.group("receiver") or "",
128
+ ))
129
+ if not _DECLARATIVE_CLIENT.search(text):
130
+ continue
131
+ for match in _JAVA_ANNOTATION_CALL.finditer(text):
132
+ line = text.count("\n", 0, match.start()) + 1
133
+ ann, args = match.group("ann"), match.group("args")
134
+ if ann == "RequestLine":
135
+ request_line = _REQUEST_LINE.search(args)
136
+ if not request_line:
137
+ unresolved.append({
138
+ "source_file": rel, "line": line,
139
+ "why": "the request line is not a literal `VERB /path`",
140
+ })
141
+ continue
142
+ calls.append(ClientCall(
143
+ method=request_line.group("verb").upper(),
144
+ path=request_line.group("path"),
145
+ source_file=rel, line=line, receiver="declarative client",
146
+ ))
147
+ continue
148
+ verb = _ANN_VERB.get(ann, "")
149
+ if not verb:
150
+ method_attr = _METHOD_ATTR.search(args)
151
+ verb = method_attr.group(1) if method_attr else ""
152
+ literal = _STRING_ARG.search(args)
153
+ route = _path_from_url(literal.group(1)) if literal else None
154
+ if not verb or route is None:
155
+ unresolved.append({
156
+ "source_file": rel, "line": line,
157
+ "why": (
158
+ "the verb is not written in the annotation"
159
+ if not verb else
160
+ "the annotation carries no literal path"
161
+ ),
162
+ })
163
+ continue
164
+ calls.append(ClientCall(
165
+ method=verb, path=route, source_file=rel, line=line,
166
+ receiver="declarative client",
167
+ ))
168
+ return calls, unresolved
169
+
170
+
171
+ def _consumer_calls(base: Path) -> "tuple[list[ClientCall], list[dict], dict]":
172
+ """Every legible call in one consumer repository, whatever it is written in."""
173
+ ts_calls, ts_unresolved = extract_client_calls(base, client_dirs=[base])
174
+ java_calls, java_unresolved = extract_java_client_calls(base)
175
+ read = {
176
+ "web_client_files": len(_iter_client_files(base, [base])),
177
+ "java_files": len(_java_files(base)),
178
+ }
179
+ return ts_calls + java_calls, ts_unresolved + java_unresolved, read
180
+
181
+
182
+ def _slug(path: str) -> str:
183
+ """A path reduced to letters and digits, lowercased — the alias key.
184
+
185
+ `/v1/agrupadorGfh` and `/v1/agrupador-gfh` are two routes with one slug: either
186
+ two spellings of one live integration, or one live route plus debris. Which of
187
+ the two is a question about consumers, and this module has the consumers.
188
+ """
189
+ return re.sub(r"[^a-z0-9/{}]", "", normalize_path(path).lower())
190
+
191
+
192
+ def build_consumer_join(
193
+ root: Path,
194
+ endpoints: "Iterable[dict]",
195
+ consumers: "Iterable[Path]",
196
+ *,
197
+ access: "Optional[dict[str, str]]" = None,
198
+ ) -> dict:
199
+ """The surface-reduction plan: what to fix, what to delete, what is undecided.
200
+
201
+ `access` maps `METHOD path` to the access verdict `posture` publishes
202
+ (`open` / `unknown` / `protected`). Absent, the plan still splits called from
203
+ uncalled — it just cannot say which half is urgent.
204
+ """
205
+ from sourcecode import non_coverage
206
+
207
+ rows = list(endpoints)
208
+ by_key: "dict[str, list[dict]]" = {}
209
+ consumer_rows: list[dict] = []
210
+ unresolved_total = 0
211
+ for base in consumers:
212
+ base = Path(base)
213
+ if not base.is_dir():
214
+ consumer_rows.append({
215
+ "path": base.as_posix(), "read": False,
216
+ "why": "not a readable directory — nothing from it is in this join",
217
+ })
218
+ continue
219
+ calls, unresolved, read = _consumer_calls(base)
220
+ unresolved_total += len(unresolved)
221
+ for call in calls:
222
+ by_key.setdefault(call.key, []).append({
223
+ **call.to_dict(), "consumer": base.name or base.as_posix(),
224
+ })
225
+ consumer_rows.append({
226
+ "path": base.as_posix(),
227
+ "name": base.name or base.as_posix(),
228
+ "read": True,
229
+ "files_read": read,
230
+ "calls_found": len(calls),
231
+ "calls_unresolved": len(unresolved),
232
+ "unresolved_sample": unresolved[:10],
233
+ })
234
+
235
+ matched_keys: set[str] = set()
236
+ routes: list[dict] = []
237
+ for endpoint in rows:
238
+ method = str(endpoint.get("method") or "").upper()
239
+ path = str(endpoint.get("path") or endpoint.get("effective_path") or "")
240
+ key = f"{method} {normalize_path(path)}"
241
+ witnesses = by_key.get(key, [])
242
+ if witnesses:
243
+ matched_keys.add(key)
244
+ verdict = (access or {}).get(f"{method} {path}") or (access or {}).get(key) or "unknown"
245
+ routes.append({
246
+ "method": method,
247
+ "path": path,
248
+ "join_key": key,
249
+ "access": verdict,
250
+ "consumed": bool(witnesses),
251
+ "match": (
252
+ "normalised" if witnesses and "{}" in key else
253
+ "exact" if witnesses else None
254
+ ),
255
+ "consumers": sorted({w["consumer"] for w in witnesses}),
256
+ "call_sites": witnesses[:5],
257
+ })
258
+
259
+ def _bucket(consumed: bool, open_only: bool) -> list[dict]:
260
+ return [
261
+ r for r in routes
262
+ if r["consumed"] is consumed
263
+ and ((r["access"] == "open") if open_only else True)
264
+ ]
265
+
266
+ fix_now = _bucket(True, True)
267
+ delete_candidates = _bucket(False, True)
268
+ unmatched = [
269
+ {"join_key": key, "call_sites": group[:5], "consumers": sorted({g["consumer"] for g in group})}
270
+ for key, group in sorted(by_key.items()) if key not in matched_keys
271
+ ]
272
+
273
+ aliases: list[dict] = []
274
+ by_slug: "dict[str, list[dict]]" = {}
275
+ for row in routes:
276
+ by_slug.setdefault(f"{row['method']} {_slug(row['path'])}", []).append(row)
277
+ for slug, group in sorted(by_slug.items()):
278
+ if len(group) < 2:
279
+ continue
280
+ live = [r for r in group if r["consumed"]]
281
+ aliases.append({
282
+ "slug": slug,
283
+ "paths": [r["path"] for r in group],
284
+ "consumed_paths": [r["path"] for r in live],
285
+ "reading": (
286
+ "every spelling is called — these are live aliases"
287
+ if len(live) == len(group) else
288
+ f"{len(live)} of {len(group)} spellings are called; the rest are "
289
+ "candidates for debris"
290
+ if live else
291
+ "no spelling is called by a consumer that was read"
292
+ ),
293
+ })
294
+
295
+ read_any = any(c.get("read") for c in consumer_rows)
296
+ return {
297
+ "schema_version": SCHEMA_VERSION,
298
+ "consumers": consumer_rows,
299
+ "summary": {
300
+ "routes": len(routes),
301
+ "consumed": sum(1 for r in routes if r["consumed"]),
302
+ "not_consumed": sum(1 for r in routes if not r["consumed"]),
303
+ "open_and_consumed": len(fix_now),
304
+ "open_and_not_consumed": len(delete_candidates),
305
+ "client_calls_matching_no_route": len(unmatched),
306
+ "calls_unresolved": unresolved_total,
307
+ },
308
+ "reduction_plan": {
309
+ "fix_now": {
310
+ "count": len(fix_now),
311
+ "meaning": (
312
+ "a readable rule leaves these reachable without authentication "
313
+ "AND a consumer that was read calls them"
314
+ ),
315
+ "routes": [r["path"] for r in fix_now[:50]],
316
+ },
317
+ "delete_candidates": {
318
+ "count": len(delete_candidates),
319
+ "meaning": (
320
+ "a readable rule leaves these open and no consumer that was read "
321
+ "calls them — candidates for deletion, which removes the exposure "
322
+ "instead of guarding it"
323
+ ),
324
+ "routes": [r["path"] for r in delete_candidates[:50]],
325
+ "before_acting": (
326
+ "This is a candidate list, not a dead-route proof: only the "
327
+ "consumers named above were read, only in call shapes whose verb "
328
+ "and path are written at the call site. Confirm against traffic "
329
+ "before deleting."
330
+ ),
331
+ },
332
+ "broken_integrations": {
333
+ "count": len(unmatched),
334
+ "meaning": (
335
+ "a consumer makes this request and this server serves no route "
336
+ "for it — the one direction that needs no assumption about "
337
+ "coverage to be true"
338
+ ),
339
+ },
340
+ },
341
+ "aliases": aliases,
342
+ "routes": routes,
343
+ "unmatched_client_calls": unmatched,
344
+ "direction": "under_reports",
345
+ "reading": (
346
+ "Of the routes a readable rule leaves open, the consumed ones are work "
347
+ "and the unconsumed ones are surface that can be removed. The population "
348
+ "read is the consumers named on the command line — this is a third "
349
+ "declared population beside the endpoint census and the servlet surface, "
350
+ "and it is never merged into either."
351
+ if read_any else
352
+ "No consumer repository could be read, so nothing was joined: every route "
353
+ "is `consumed: false` because there was nothing to call it. That is an "
354
+ "absence of evidence, published as one."
355
+ ),
356
+ "non_coverage": non_coverage.block("client_usage"),
357
+ }
@@ -363,13 +363,68 @@ def _signal(
363
363
  )
364
364
 
365
365
 
366
- def collect_env_file_signals(path: Path, *, root: Optional[Path] = None) -> list[Signal]:
367
- """Signals from a caller-supplied deployment env/dotenv file.
366
+ #: The XML shape a deployment plan uses to override an environment entry: a name
367
+ #: tag naming the setting, and its value in the next tag. WebLogic deployment
368
+ #: plans (`<variable><name>…</name><value>…</value></variable>`) and servlet
369
+ #: `<context-param>` blocks are the same shape with different tag names.
370
+ _XML_NAME_TAG = re.compile(
371
+ r"<(?:param-)?name>\s*(?:" + re.escape(_PROPERTY_KEY) + r"|" + _ENV_KEY
372
+ + r")\s*</(?:param-)?name>",
373
+ re.IGNORECASE,
374
+ )
375
+ _XML_VALUE_TAG = re.compile(r"<(?:param-)?value>\s*([^<]*)</(?:param-)?value>")
376
+
377
+ #: The tree scan ends an unquoted `KEY=value` at the first comma, because in a
378
+ #: repository a comma is usually the next item of a flow list rather than the
379
+ #: next profile. A deployment artefact the caller *names* is the opposite case:
380
+ #: `SPRING_PROFILES_ACTIVE=prod,metrics` is one deployment activating two
381
+ #: profiles, and dropping `metrics` would answer under a profile set the
382
+ #: deployment does not run. Same patterns otherwise, comma admitted.
383
+ _DEPLOYMENT_LINE_PATTERNS: tuple[tuple[str, re.Pattern], ...] = tuple(
384
+ (label, re.compile(_ENV_KEY + r"\s*[:=]\s*([^\s\"']+)")) if label == "env"
385
+ else (label, pattern)
386
+ for label, pattern in _LINE_PATTERNS
387
+ )
388
+
389
+
390
+ def _next_xml_value(lines: list[str], after: int) -> Optional[str]:
391
+ """The `<value>`/`<param-value>` that belongs to the name tag on line *after*.
392
+
393
+ Bounded to the next few lines on purpose: a value that is not adjacent to its
394
+ name is not a pair this reader can prove, and guessing at distance is how a
395
+ deployment artefact starts deciding things it does not say.
396
+ """
397
+ for line in lines[after - 1:after + 3]:
398
+ match = _XML_VALUE_TAG.search(line)
399
+ if match:
400
+ return _clean(match.group(1))
401
+ return None
402
+
368
403
 
369
- Unlike repository artefacts, this is evidence the reviewer explicitly supplies
370
- for the deployment being audited, so a deciding value outranks defaults found
371
- in the tree. It still travels as ordinary evidence: file, line, raw text and
372
- placeholder handling are the same as repository scans.
404
+ def collect_deployment_artefact_signals(
405
+ path: Path, *, root: Optional[Path] = None
406
+ ) -> list[Signal]:
407
+ """Signals from ONE deployment artefact the caller names (F-BG).
408
+
409
+ The artefact that decides the profile set lives outside the code — a
410
+ Kubernetes manifest, a compose file, a WebLogic deployment plan, a
411
+ Dockerfile, a dotenv, a launch script — and `posture` used to end at exactly
412
+ that boundary: *"SPRING_PROFILES_ACTIVE in the process environment outranks
413
+ every artefact below it, and this analysis cannot see the process
414
+ environment"*. It still cannot see the process environment; what it can now
415
+ read is the file that fills it, when a reviewer says which one.
416
+
417
+ This is evidence the reviewer explicitly supplies for the deployment being
418
+ audited, so a deciding value here outranks defaults found in the tree. It
419
+ travels as ordinary evidence — `file:line`, the raw text, the same
420
+ placeholder handling — and it decides nothing on its own: a value that is a
421
+ `${...}` placeholder, or absent, leaves the repository's own verdict standing
422
+ rather than inventing one.
423
+
424
+ Every shape is read the same way as the repository scan, because they are the
425
+ same artefact families: the three line patterns (`-D`, `KEY=`, `key: `), the
426
+ two-line Kubernetes `name:`/`value:` pair, and the XML name/value pair used by
427
+ deployment plans and `<context-param>` blocks.
373
428
  """
374
429
  source = Path(path).expanduser().resolve()
375
430
  root_path = Path(root).resolve() if root is not None else None
@@ -380,21 +435,154 @@ def collect_env_file_signals(path: Path, *, root: Optional[Path] = None) -> list
380
435
  rel = source.as_posix()
381
436
 
382
437
  signals: list[Signal] = []
383
- for index, line in enumerate(text.splitlines(), start=1):
438
+ lines = text.splitlines()
439
+ for index, line in enumerate(lines, start=1):
384
440
  stripped = line.strip()
441
+ # A commented line states nothing: dotenv and YAML both use `#`, and an
442
+ # artefact's history is not its content.
385
443
  if not stripped or stripped.startswith("#"):
386
444
  continue
387
- for _label, pattern in _LINE_PATTERNS:
445
+ for _label, pattern in _DEPLOYMENT_LINE_PATTERNS:
388
446
  match = pattern.search(line)
389
- if match:
447
+ if not match:
448
+ continue
449
+ value = _clean(match.group(1))
450
+ if not value and _K8S_NAME.search(stripped):
451
+ value = _next_k8s_value(lines, index)
452
+ signals.append(_signal("deployment_input", rel, index, line, value, explicit=True))
453
+ break
454
+ else:
455
+ if _K8S_NAME.search(stripped):
390
456
  signals.append(_signal(
391
- "deployment_input", rel, index, line, _clean(match.group(1)),
392
- explicit=True,
457
+ "deployment_input", rel, index, line,
458
+ _next_k8s_value(lines, index), explicit=True,
459
+ ))
460
+ elif _XML_NAME_TAG.search(line):
461
+ signals.append(_signal(
462
+ "deployment_input", rel, index, line,
463
+ _next_xml_value(lines, index), explicit=True,
393
464
  ))
394
- break
395
465
  return signals
396
466
 
397
467
 
468
+ def collect_env_file_signals(path: Path, *, root: Optional[Path] = None) -> list[Signal]:
469
+ """Signals from a caller-supplied deployment env/dotenv file.
470
+
471
+ Retained name for `--env-file`; the reader is the general one (F-BG), because
472
+ a dotenv is one deployment artefact shape and there was never a reason for it
473
+ to be the only one this product could be handed.
474
+ """
475
+ return collect_deployment_artefact_signals(path, root=root)
476
+
477
+
478
+ #: The `--profile-from` block. Versioned separately from the resolution above
479
+ #: because it answers a narrower question — *did the artefacts you named decide
480
+ #: the profile set* — and a consumer may read it without reading anything else.
481
+ PROFILE_FROM_SCHEMA = "profile-from-v1"
482
+
483
+ #: The deciding outcome of `--profile-from`. Deliberately *not* `DECIDED`: that
484
+ #: constant says "this repository decides it", and the whole point of this flag is
485
+ #: that the deciding artefact lives outside the repository.
486
+ DECIDED_BY_ARTEFACT = "decided_by_deployment_artefact"
487
+
488
+ #: What outranks every artefact, said once and reused wherever the block is
489
+ #: published, so the one caveat that matters cannot be dropped by a caller.
490
+ PROCESS_ENV_CAVEAT = (
491
+ "SPRING_PROFILES_ACTIVE in the process environment outranks every artefact, "
492
+ "including this one; what is claimed is what the named artefact sets, not "
493
+ "what the running process was given."
494
+ )
495
+
496
+
497
+ def decide_profiles_from_artefacts(
498
+ paths: Iterable[Path], *, root: Optional[Path] = None
499
+ ) -> dict:
500
+ """Read the named deployment artefacts and say whether they decide the profile set.
501
+
502
+ Three outcomes, and only the first one changes what `posture` answers:
503
+
504
+ * **decided** — exactly one distinct profile set across every artefact named.
505
+ The set is published with the `file:line` that states it.
506
+ * **artefacts_disagree** — two artefacts name different sets. They are two
507
+ deployments, not a contradiction to resolve, so nothing is decided here for
508
+ the same reason the repository scan refuses it.
509
+ * **not_decided** — the artefacts name the setting without a value, or state a
510
+ placeholder resolved elsewhere, or never mention it at all. The repository's
511
+ own verdict stands untouched; this is the undecidable-first contract, and it
512
+ is what makes the flag safe to point at the wrong file.
513
+ """
514
+ root_path = Path(root).resolve() if root is not None else None
515
+ artefacts: list[dict] = []
516
+ signals: list[Signal] = []
517
+ for raw_path in paths:
518
+ source = Path(raw_path).expanduser()
519
+ found = collect_deployment_artefact_signals(source, root=root_path)
520
+ signals.extend(found)
521
+ artefacts.append({
522
+ "path": source.as_posix(),
523
+ "signals": [s.to_dict() for s in found],
524
+ "states_the_setting": bool(found),
525
+ })
526
+
527
+ deciding = [s for s in signals if s.value]
528
+ distinct = sorted({s.value for s in deciding if s.value})
529
+
530
+ block: dict = {
531
+ "schema_version": PROFILE_FROM_SCHEMA,
532
+ "artefacts": artefacts,
533
+ "outranked_by": PROCESS_ENV_CAVEAT,
534
+ }
535
+ if len(distinct) > 1:
536
+ block.update({
537
+ "decided": False,
538
+ "reason": DISAGREE,
539
+ "profiles": None,
540
+ "decided_by": None,
541
+ "statement": (
542
+ "The artefacts you named set different active profile sets ("
543
+ + ", ".join(repr(v) for v in distinct)
544
+ + "). These are different deployments, not a contradiction to "
545
+ "resolve — name the one artefact that describes the deployment "
546
+ "you are auditing. Until then the repository's own verdict stands."
547
+ ),
548
+ })
549
+ return block
550
+ if not distinct:
551
+ named = sum(1 for a in artefacts if a["states_the_setting"])
552
+ block.update({
553
+ "decided": False,
554
+ "reason": "no_value_here",
555
+ "profiles": None,
556
+ "decided_by": None,
557
+ "statement": (
558
+ (
559
+ f"{named} artefact(s) name the setting and none of them states a "
560
+ "value here — a placeholder or an empty entry names where the "
561
+ "value comes from, not what it is. "
562
+ if named else
563
+ "The artefact(s) you named do not set `spring.profiles.active` or "
564
+ "`SPRING_PROFILES_ACTIVE`. "
565
+ )
566
+ + "The repository's own verdict is unchanged."
567
+ ),
568
+ })
569
+ return block
570
+
571
+ first = next(s for s in deciding if s.value == distinct[0])
572
+ block.update({
573
+ "decided": True,
574
+ "reason": DECIDED_BY_ARTEFACT,
575
+ "profiles": list(_split_profiles(distinct[0])),
576
+ "decided_by": f"{first.file}:{first.line}",
577
+ "statement": (
578
+ f"{first.file}:{first.line} sets the active profile set to "
579
+ f"{distinct[0]!r}, so that is the profile set this posture answers "
580
+ "under. " + PROCESS_ENV_CAVEAT
581
+ ),
582
+ })
583
+ return block
584
+
585
+
398
586
  def resolve_environment(
399
587
  root: Path,
400
588
  deployment_signals: Optional[Iterable[Signal]] = None,
@@ -50,6 +50,7 @@ FORMAT_REGISTRY: "dict[str, tuple[str, ...]]" = {
50
50
  "rename-class": ("json", "yaml"),
51
51
  "risk": ("json", "yaml"),
52
52
  "trend": ("json", "yaml"),
53
+ "timeline": ("json", "yaml"),
53
54
  "verify": ("json", "yaml"),
54
55
  "verify-edit": ("json", "yaml"),
55
56
  "impact": ("json", "yaml"),
@@ -119,17 +119,21 @@ NON_COVERAGE: tuple[NonCoverage, ...] = (
119
119
  "General request-input-to-sink taint analysis."
120
120
  ),
121
121
  why=(
122
- "`ask risk` includes a bounded slice for annotated HTTP parameters, "
123
- "positional calls in repository Java sources and known query sinks. "
124
- "Anything broader needs value flow through a program, which is a "
125
- "different analysis with a different failure mode: an unsound one "
126
- "produces confident findings that are wrong, and every claim here is "
127
- "meant to be checkable against the line that produced it."
122
+ "`ask risk` includes two bounded slices for annotated HTTP parameters "
123
+ "and positional calls in repository Java sources: known query sinks, "
124
+ "and MyBatis statements that splice a value into their SQL. Anything "
125
+ "broader needs value flow through a program, which is a different "
126
+ "analysis with a different failure mode: an unsound one produces "
127
+ "confident findings that are wrong, and every claim here is meant to "
128
+ "be checkable against the line that produced it."
128
129
  ),
129
130
  instead=(
130
131
  "A dataflow scanner (Semgrep, CodeQL) for complete taint. What this "
131
- "product adds on top is reachability and, for the bounded slice, "
132
- "whether an annotated HTTP input reaches a known concatenated query sink."
132
+ "product adds on top is reachability and, for the two bounded slices, "
133
+ "whether an annotated HTTP input reaches a known concatenated query "
134
+ "sink or a spliced SQL statement — the latter with a three-state "
135
+ "verdict that says `undecidable` rather than clearing a route it could "
136
+ "not follow."
133
137
  ),
134
138
  ),
135
139
  NonCoverage(
sourcecode/perf.py CHANGED
@@ -460,7 +460,7 @@ RELEASE_GATE_MIN_RUNS = 5
460
460
  #: what is left out is left out for a reason, and the reason is published below.
461
461
  RELEASE_GATE_COMMANDS: tuple[str, ...] = (
462
462
  "", "endpoints", "spring-audit", "migrate-check", "validation", "posture",
463
- "risk", "modernize", "repo-ir", "data-exposure",
463
+ "risk", "modernize", "repo-ir",
464
464
  )
465
465
 
466
466
  #: Extra arguments a gated command needs to be measured at all — one authority,
@@ -473,6 +473,13 @@ RELEASE_GATE_COMMANDS: tuple[str, ...] = (
473
473
  #: form the harness already uses to count symbols.
474
474
  RELEASE_GATE_COMMAND_ARGS: dict[str, tuple[str, ...]] = {
475
475
  "repo-ir": ("--summary-only",),
476
+ # The first gated release found this one: `ask migrate-check <gate repo>`
477
+ # exits 1 with OUTPUT_TOO_LARGE (~269 K estimated tokens on 2 985 files), and
478
+ # for five releases the harness recorded the timing anyway — the analysis did
479
+ # run, so the number looked plausible (8,72 s) and it was the cost of a run
480
+ # that ended in a refusal. `--compact` is the command's own published remedy
481
+ # ("bounded decision summary, same report") and it measures the same scan.
482
+ "migrate-check": ("--compact",),
476
483
  }
477
484
 
478
485
  #: What the gate does **not** cover, and why — published rather than omitted, so
@@ -491,6 +498,13 @@ RELEASE_GATE_EXCLUSIONS: tuple[tuple[str, str], ...] = (
491
498
  ("pr-impact / review-pr", "diff-dependent — their cost follows a diff the "
492
499
  "gate would have to invent, and an invented diff "
493
500
  "measures the invention"),
501
+ ("data-exposure", "needs `dataLabels` declared in the repository under audit, "
502
+ "and the gate repository declares none: measured at 0,28 s "
503
+ "on the first gated release, which is the cost of the "
504
+ "abstention (`answered: false`) and not of the command. A "
505
+ "cell that times a refusal is worse than no cell — it is a "
506
+ "green light for work nobody did (found by this gate, on "
507
+ "its first run over a release)"),
494
508
  )
495
509
 
496
510