sourcecode 5.8.13__py3-none-any.whl → 5.8.14__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
@@ -4,4 +4,4 @@ ASK Engine is the product. ``ask`` is the canonical CLI command; ``sourcecode``
4
4
  the legacy compatibility alias and the Python/PyPI package name. See
5
5
  docs/PRODUCT_IDENTITY.md (normative)."""
6
6
 
7
- __version__ = "5.8.13"
7
+ __version__ = "5.8.14"
@@ -16,24 +16,49 @@ The facts these rows are keyed to are published: `ask schema facts-v1` prints th
16
16
 
17
17
  ## Current Synchronization
18
18
 
19
- **As of 2026-08-20, release `5.8.13` and the external `5.8.11` re-audit:** this is the current status of
20
- the 5.8.8/5.8.9 audit queue. The subject remained `saint-server` at `3dde0376`, with a
21
- byte-identical CIR and the same 53/61 invocables exercised. The score is **78/100**
22
- (75 in 5.8.8, 80 in 5.8.9). The observations below remain historical evidence; only
23
- this table declares present state.
19
+ **As of 2026-08-20, release `5.8.14` and the fourth external re-audit:** the audited
20
+ snapshot remains `3dde0376`; the report compares the four audit rounds and scores this one
21
+ **90/100** (75, 80, 78, 90). It exercised 53/61 invocables (87%), ran no application and
22
+ left the repository unchanged. Its 40+ longitudinal functional anchors are unchanged, so
23
+ there is no reported product regression. The `5.8.14` release ships the implementations
24
+ recorded below; they remain distinguished from facts verified on the `5.8.13` subject.
25
+ Only this table declares present state.
26
+
27
+ **Release status:** entries marked “pending `5.8.14`” in their historical wording are
28
+ shipped in `5.8.14`; a re-audit is still required wherever the row says so.
24
29
 
25
30
  | Rows | Current status | Evidence |
26
31
  |---|---|---|
27
- | `AUD-588-B01`, `AUD-589-B01`, `C3-122` | **implemented in 5.8.12** | `9884488` requires `risk` and `audit-report` to deliver stdout or their requested artifact before exit 0; the remaining closure evidence is a field-scale subprocess reproduction. |
32
+ | `AUD-588-B01` / `AUD-589-B01`, `C3-122` | **open, P0 for `risk`; `audit-report` corrected** | `9884488` corrected `audit-report` to refuse with typed `OUTPUT_TOO_LARGE` and produce its requested artifact. `risk` nevertheless exited 0 twice on the first shared-cache invocations with 0 bytes and no `-o` file, then succeeded seven times. `5cfa9ba` now publishes the source, audit-finding, candidate-defect and composed-defect working set, including partial answers. Repeat the real entry-point protocol before closure. |
28
33
  | `AUD-511-R01` / `AUD-588-B03` | **implemented in 5.8.12** | `13362ec` makes SEC-009 read comment-blanked Java, so a Javadoc profile cannot override the live `@Profile("!m3")` declaration. |
29
34
  | `AUD-588-B04` | **retracted audit premise** | `data-exposure` correctly preserves `chain_decision`; the reported `coverage_unknown` routes were custom-gate annotations whose application was not configured. `fd34eb1` remains a valid additive disclosure, but no further B04 fix is due. |
30
35
  | `AUD-588-B05` | **closed** | `c15f5b6`; changed container-wired components block a false `unaffected` disposition. |
31
- | `AUD-511-R03` / `AUD-588-B07` | **implemented in 5.8.12** | `47aee1e` gives the published `OUTPUT_TOO_LARGE` envelope precedence over an inconsistent process status and preserves the excerpt in every branch. |
36
+ | `AUD-511-R03` / `AUD-588-B07` | **implemented, pending 5.8.14 re-audit** | `f613df9` makes ASK-09 parse the typed document from stdout or stderr, including a progress-prefixed stderr stream, and removes the four inherited variables that change its precondition. The regression exercises the real typed refusal shape. The prior claim that the payload premise was false remains retracted as an environment-contamination artifact. |
32
37
  | `AUD-588-B08` | **closed** | `c084555`; `impact` publishes its independent stdout-byte budget. |
33
38
  | `MCP-001` | **closed 5.8.13** | Deep corpus testing reproduced concurrent `CliRunner` stream capture leaking a tool response to the host stdout. `b2c9660` serializes the narrow process-global capture seam; concurrent real MCP calls and a regression test verify response isolation. |
34
- | `AUD-588-B11` (container/units subset) | **partially implemented in 5.8.12** | `d745d31` discovers nested Maven modules; `273ae8e` suppresses unsupported `pr-impact.unaffected_basis`. `impact` naming, source population units, risk tiers and plan narrative remain open. |
35
- | `AUD-511-R02` / `AUD-589-B02` | **partially mitigated in 5.8.12, still under investigation** | `b93f230` reuses one `SpringSemanticModel` across every declared `data-exposure` seed; `473f47a` routes `validation` through the shared CIR cache, and `91bd0c0` retains subcommand `--no-cache` compatibility. On the local BroadleafCommerce corpus at the final analyzer fingerprint, a first shared-CIR fill took 10.46 s and its warm `validation --compact` hit took 4.01 s with byte-identical output. This is a same-host cache-state observation, not the 5.8.9/5.8.11 external A/B: the six-run C3-120 comparison and the configured `saint-server` reproduction remain required. |
36
- | `AUD-588-B02`, `B06`, B11 residuals, `B12` | **partially open in 5.8.12** | `3869c87` excludes explicit EclipseLink from Hibernate applicability and effort; `037223e` publishes provider non-coverage. B11 parity and controlled cost calibration remain required. |
39
+ | `AUD-513-N01` (`N-1`) | **closed in 5.8.14** | `rename-class` rewrote three Petclinic files under both `ASK_READONLY=1` and `--no-write`, then exited 0. The audit restored the tree; this is a write-policy breach, not an artifact-directory exception. `readonly.guard_mutation()` now refuses before any planned source write or physical rename; help names the command as mutating and regressions cover both readonly controls plus `--dry-run`. |
40
+ | `AUD-513-N02` / `B-4` / `N-5` | **N02/B-4 implemented in 5.8.14; N-5 open** | `posture.summary.unconditional` counted 1,697 on a non-Spring Struts tree and 1,573 on Mall, and `security` was `0/0/0` with no population. The posture projection now uses the canonical Spring IoC bean population, publishes its population/unit and separates unconditional security beans. Regressions cover non-Spring, mixed-bean and conditional/unconditional security fixtures. Progress/advice still use incompatible “Java files” scopes (`N-5`), so the `AUD-588-F02` family remains open. |
41
+ | `AUD-513-B01` / `B-3` / `B-5` | **B01/B-5 implemented in 5.8.14; B-3 closed as revalidated** | A MyBatis XML paired with a `*Mapper.java` interface now takes precedence over an absent `@Mapper` annotation, preserving XML-backed/`@MapperScan` interfaces in `mapper_interfaces` rather than misclassifying them as DTO mappers. `imports_found` now contains only actual Java imports; code, XML and build matches are emitted as `evidence`, including in compact output. `B-3` was stale against the current source: both views consume `JAVA_SPRING_SECTIONS`; a real MyBatis Mapper/XML regression proves the section is retained by `--agent`. |
42
+ | `AUD-513-N03/N04/N07/N08/N09`, `B-6/B-7/B-8`, `D-09` | **N03/N04/N07/N08/N09 implemented in 5.8.14; remainder open** | `impact` now always publishes the `candidates` field its not-found message names, including `[]` when no close symbol exists. Budget advice no longer calls partial snapshot/RIS evidence a warm cache: it identifies that presence and says context/parse are unknown before analysis, whose payload then reports all four actual layers. README operational sections (`clone`, install, build, getting started, troubleshooting and related headings) are never promoted into a project summary; with no descriptive prose compact output publishes `project_summary: null` and `summary_basis`. `security_posture.limitations` is now cache-state invariant; dynamic execution advice travels in `security_posture.operational_hints`. Every numeric output bound rejects negative values at parse time with a structured `INVALID_INPUT` envelope (`flag`, `value`, `expected`); `--limit 0` still means no cap wherever that behavior was published. Budgets overrun, timing coverage is inconsistent, compact hides unresolved identities and cost anchors remain stale. |
43
+ | `AUD-588-B11` (container/units subset) | **partially implemented in 5.8.14** | `d745d31` discovers nested Maven modules; `273ae8e` suppresses unsupported `pr-impact.unaffected_basis`. `e53507d` separates direct impact matches from analysis seeds and implementation classes; `8386744` carries the established container-wiring fact into plan checklists and compare cost dimensions. Source population units and risk-tier wording remain open. |
44
+ | `AUD-511-R02` / `AUD-589-B02` / `AUD-513-N06` | **partially corrected; `data-exposure` open, P1** | Nine of ten measured commands recovered, many to their best historical timings. `data-exposure --config` did not: 42.7 s versus 16.2 s in 5.8.8 and a 28.9 s model, with byte-equivalent output. Measure it with the idle-host, isolated-cache, interleaved-control protocol before changing code, then compare its reuse path with `validation`. |
45
+ | `AUD-588-B02`, `B06`, B11 residuals, `B12` | **B02/B06 closed; B11/B12 open, P2** | `3869c87` excludes explicit EclipseLink from Hibernate applicability and effort; `037223e` publishes provider non-coverage. The re-audit verifies both and the payload reduction; `cf57457` makes aggregate wording name only applicable dimensions. Complete B11 consumer parity and calibrate B12 only from provenance-bearing controlled samples. |
46
+
47
+ ### Fourth Re-audit: 5.8.13
48
+
49
+ The fourth pass verifies the current facts that supersede the prior intake. There is no
50
+ crash/traceback and no regression in the published anchors. `B-2/D-05` remains a retracted
51
+ servlet premise, and the audit additionally verifies EclipseLink applicability/non-coverage,
52
+ typed `audit-report` refusal, nested Maven discovery and the `unaffected_basis` correction.
53
+ It leaves the following concrete queue rather than reopening closed facts.
54
+
55
+ | Recommendation | Status and direction |
56
+ |---|---|
57
+ | `AUD-588-F02` shared answer contracts | **Partial.** Reuse the existing authorities for exact target versus implementation cone, named source populations and container reach in plan/compare; do not add a parallel detector. |
58
+ | `AUD-588-F03` versioned cost calibration | **Open.** Capture repository, cache state, sample count and host provenance. The immediately actionable measurement is `data-exposure --config`; clean data is required before recalibration. |
59
+ | Risk cache observability | **Recommended hardening.** Publish working-set unit counts under progress or in the envelope to distinguish an empty compositor from a present-but-incomplete cache. |
60
+ | `AUD-588-F05` coverage expansion | **Locally verified only.** Dirty-tree `verify-edit` and the eight mutating/denied invocables remain unexercised in all four external rounds. |
61
+ | EclipseLink migration automation | **Deferred product feature.** The current applicability and non-coverage facts are verified; provider-specific migration automation is not a corrective fix. |
37
62
 
38
63
  ### Re-audit Intake: 5.8.11
39
64
 
@@ -138,7 +138,7 @@ pipx install sourcecode # isolated install, no venv needed
138
138
 
139
139
  # Verify
140
140
  ask version
141
- # ask 5.8.13
141
+ # ask 5.8.14
142
142
  ```
143
143
 
144
144
  Requires Python 3.9+.
sourcecode/change_plan.py CHANGED
@@ -139,7 +139,9 @@ def build_change_plan(
139
139
  ),
140
140
  }
141
141
 
142
- seeds = list(blast["matched_fqns"])
142
+ # The user-facing match names the requested target; planning must retain the
143
+ # subtype-expanded seeds that the blast-radius authority actually analysed.
144
+ seeds = list(blast.get("analysis_seed_fqns") or blast["matched_fqns"])
143
145
  container_wired = [w.to_dict() for w in detect_classes(cir, seeds)]
144
146
  meta = _node_meta(cir)
145
147
  affected = _dependents_closure(cir.reverse_graph or {}, seeds, max_depth, _NODE_CAP)
@@ -210,6 +212,11 @@ def build_change_plan(
210
212
  checklist.append(
211
213
  f"{len(sec)} secured endpoint(s) in scope — re-check authorization."
212
214
  )
215
+ if container_wired:
216
+ checklist.append(
217
+ f"{len(container_wired)} container-wired component(s) are in scope — "
218
+ "review their declared framework wiring because fan-in does not measure it."
219
+ )
213
220
  txn = blast.get("transactional_boundaries_touched") or []
214
221
  if txn:
215
222
  checklist.append(f"{len(txn)} transactional boundary/boundaries in scope.")
sourcecode/cli.py CHANGED
@@ -6,6 +6,7 @@ import hashlib
6
6
  import json
7
7
  import difflib
8
8
  import os
9
+ import re
9
10
  import sys
10
11
  import tempfile
11
12
  import threading
@@ -287,6 +288,7 @@ COMMANDS_THAT_WRITE: "tuple[tuple[str, str], ...]" = (
287
288
  ("migrate-check", "`<repo>/.ask/readiness-history/` — only with `--history`"),
288
289
  ("verify", "`<repo>/.ask/contracts-baseline.json` — only with `--update-baseline`"),
289
290
  ("verify-edit", "the repository's git hooks directory — only with `--install-hook`"),
291
+ ("rename-class", "`<repo>/**/*.java` — only without `--dry-run`; existing sources change and the declaring file is renamed"),
290
292
  ("*", "`<repo>/.ask/runs/` — only when `ASK_RUNS_IN_REPO=1` is set"),
291
293
  )
292
294
 
@@ -294,7 +296,7 @@ COMMANDS_THAT_WRITE: "tuple[tuple[str, str], ...]" = (
294
296
  def _writes_help_block() -> str:
295
297
  """The `--no-write` table as `--help` prints it — generated, never typed."""
296
298
  lines = [
297
- "Commands that create files inside the repository (everything else reads only):",
299
+ "Commands that modify files inside the repository (everything else reads only):",
298
300
  "",
299
301
  ]
300
302
  width = max(len(name) for name, _ in COMMANDS_THAT_WRITE)
@@ -1625,7 +1627,10 @@ def _budget_evidence(command: str, path: "Optional[Path]") -> "tuple[Optional[st
1625
1627
  if path is not None:
1626
1628
  measured = _plan.scope_for(Path(path))
1627
1629
  if measured is not None and measured.measured:
1628
- scope = measured.describe()
1630
+ # The scope probe can cheaply see only the snapshot/RIS store.
1631
+ # Do not turn that partial observation into "cache warm" while
1632
+ # a command's actual context/parse state is still unknown.
1633
+ scope = measured.describe_for_budget()
1629
1634
  except Exception:
1630
1635
  pass # a message about cost must never be the reason a run fails
1631
1636
  return anchors, scope
@@ -2325,6 +2330,32 @@ def _root_flag_usage(flag: str) -> str:
2325
2330
  return f"`ask {flag} <path>`"
2326
2331
 
2327
2332
 
2333
+ def _numeric_range_error_context(message: str) -> dict[str, object]:
2334
+ """Extract structured facts from Click's lower-bound validation message.
2335
+
2336
+ Click owns parser-time validation, so commands never start work for an
2337
+ invalid value. Its stock message contains the useful facts but leaves
2338
+ automation to parse prose; expose those facts in the shared error envelope.
2339
+ """
2340
+ prefix, separator, remainder = message.partition(": ")
2341
+ if not separator or not prefix.startswith("Invalid value for "):
2342
+ return {}
2343
+ minimum = re.search(
2344
+ r"is not in (?:the )?range (?:x>=(\d+)|(\d+)<=x(?:<=\d+)?)",
2345
+ remainder,
2346
+ )
2347
+ value = re.match(r"(-?\d+)\s+is not in", remainder)
2348
+ flags = re.findall(r"'(--[^']+)'", prefix)
2349
+ if minimum is None or value is None or not flags:
2350
+ return {}
2351
+ lower_bound = minimum.group(1) or minimum.group(2)
2352
+ return {
2353
+ "flag": flags[0],
2354
+ "value": int(value.group(1)),
2355
+ "expected": f"an integer >= {lower_bound}",
2356
+ }
2357
+
2358
+
2328
2359
  try:
2329
2360
  import click.exceptions as _click_exc
2330
2361
 
@@ -2386,7 +2417,11 @@ try:
2386
2417
 
2387
2418
  def _json_click_usage_error_show(self: Any, file: Any = None) -> None: # type: ignore[override]
2388
2419
  import json as _je
2420
+ _message = self.format_message()
2421
+ _numeric_context = _numeric_range_error_context(_message)
2389
2422
  _flag, _hint = _misplaced_global_flag_hint(self)
2423
+ if _numeric_context:
2424
+ _flag = str(_numeric_context["flag"])
2390
2425
  if not _hint:
2391
2426
  _nearby = _nearby_command_flag_hint(self)
2392
2427
  if _nearby:
@@ -2396,10 +2431,13 @@ try:
2396
2431
  _context["flag"] = _flag
2397
2432
  if _hint:
2398
2433
  _context["scope"] = "root"
2434
+ if _numeric_context:
2435
+ _context["value"] = _numeric_context["value"]
2399
2436
  payload = build_error_envelope(
2400
2437
  INVALID_INPUT_CODE,
2401
- self.format_message(),
2438
+ _message,
2402
2439
  hint=_hint,
2440
+ expected=(str(_numeric_context["expected"]) if _numeric_context else None),
2403
2441
  **_context,
2404
2442
  )
2405
2443
  sys.stderr.write(_je.dumps(payload, ensure_ascii=_json_ensure_ascii()) + "\n")
@@ -5086,7 +5124,9 @@ def main(
5086
5124
  sm.key_dependencies = _deduped_deps # no cap — all direct deps included
5087
5125
 
5088
5126
  # LQN-02: deterministic NL summary
5089
- sm.project_summary = ProjectSummarizer(target).generate(sm)
5127
+ _summarizer = ProjectSummarizer(target)
5128
+ sm.project_summary = _summarizer.generate(sm)
5129
+ sm.summary_basis = _summarizer.summary_basis
5090
5130
  sm.architecture_summary = ArchitectureSummarizer(target).generate(sm)
5091
5131
 
5092
5132
  # Phase 13 Plan 04: Architectural Inference (--architecture flag)
@@ -6593,11 +6633,13 @@ def repo_ir_cmd(
6593
6633
  None,
6594
6634
  "--max-nodes",
6595
6635
  help="Limit graph.nodes to top N by impact score (reduces output size)",
6636
+ min=0,
6596
6637
  ),
6597
6638
  max_edges: Optional[int] = typer.Option(
6598
6639
  None,
6599
6640
  "--max-edges",
6600
6641
  help="Limit graph.edges to N (priority: edges between kept nodes)",
6642
+ min=0,
6601
6643
  ),
6602
6644
  summary_only: bool = typer.Option(
6603
6645
  False,
@@ -7188,6 +7230,7 @@ def endpoints_cmd(
7188
7230
  limit: Optional[int] = typer.Option(
7189
7231
  None, "--limit", "-n",
7190
7232
  help="Maximum number of endpoints to return.",
7233
+ min=0,
7191
7234
  ),
7192
7235
  compact: bool = typer.Option(
7193
7236
  False, "--compact",
@@ -7946,6 +7989,7 @@ def validation_cmd(
7946
7989
  None, "--limit",
7947
7990
  help="Keep at most N rows per list (endpoints, gaps, validators); the "
7948
7991
  "summary stays whole and each cut list declares what it omitted.",
7992
+ min=0,
7949
7993
  ),
7950
7994
  compact: bool = typer.Option(
7951
7995
  False, "--compact",
@@ -10630,7 +10674,7 @@ def risk_cmd(
10630
10674
  help="Repository path (default: current directory).",
10631
10675
  ),
10632
10676
  limit: int = typer.Option(
10633
- 50, "--limit", help="How many composed risks to publish (highest first)."
10677
+ 50, "--limit", help="How many composed risks to publish (highest first).", min=0,
10634
10678
  ),
10635
10679
  table: bool = typer.Option(
10636
10680
  False,
@@ -10841,7 +10885,7 @@ def enrich_cmd(
10841
10885
  ),
10842
10886
  ),
10843
10887
  limit: int = typer.Option(
10844
- 50, "--limit", help="How many enriched findings to publish (highest first)."
10888
+ 50, "--limit", help="How many enriched findings to publish (highest first).", min=0,
10845
10889
  ),
10846
10890
  table: bool = typer.Option(
10847
10891
  False,
@@ -11501,6 +11545,7 @@ def posture_cmd(
11501
11545
  "--limit",
11502
11546
  help="Keep at most N beans per list (active/inactive/unresolved); the "
11503
11547
  "counts stay whole and each cut list declares what it omitted.",
11548
+ min=0,
11504
11549
  ),
11505
11550
  compact: bool = typer.Option(
11506
11551
  False,
@@ -11988,6 +12033,8 @@ def migrate_check_cmd(
11988
12033
  depth: int = typer.Option(
11989
12034
  4, "--depth",
11990
12035
  help="Caller BFS depth for --blast-radius (1-8, default: 4).",
12036
+ min=1,
12037
+ max=8,
11991
12038
  ),
11992
12039
  detach: bool = _detach_option(),
11993
12040
  progress_mode: Optional[str] = _progress_option(),
@@ -12313,6 +12360,7 @@ def impact_chain_cmd(
12313
12360
  help="Maximum items per list section (callers, endpoints, security "
12314
12361
  "surfaces). Totals stay exact in metadata/truncated_lists. "
12315
12362
  "0 disables the cap.",
12363
+ min=0,
12316
12364
  ),
12317
12365
  with_findings: bool = typer.Option(
12318
12366
  False, "--with-findings",
@@ -12636,7 +12684,8 @@ def explain_cmd(
12636
12684
  limit: int = typer.Option(
12637
12685
  50, "--limit",
12638
12686
  help="Maximum items per list section (callers, methods, deps). "
12639
- "0 disables the cap. Truncation is always reported in warnings.",
12687
+ "0 disables the cap. Truncation is always reported in warnings.",
12688
+ min=0,
12640
12689
  ),
12641
12690
  copy: bool = _copy_option(),
12642
12691
  progress_mode: Optional[str] = _progress_option(),
@@ -13502,10 +13551,12 @@ def chunk_file_cmd(
13502
13551
  max_lines: int = typer.Option(
13503
13552
  500, "--max-lines", "-n",
13504
13553
  help="Target max lines per chunk (default: 500). Methods > max_lines emit size_warning.",
13554
+ min=1,
13505
13555
  ),
13506
13556
  chunk_id: Optional[int] = typer.Option(
13507
13557
  None, "--chunk", "-c",
13508
13558
  help="Return only this chunk by ID (1-based). Omit to return all chunks.",
13559
+ min=1,
13509
13560
  ),
13510
13561
  metadata_only: bool = typer.Option(
13511
13562
  False, "--metadata-only",
@@ -16476,7 +16527,7 @@ def main_entry() -> None:
16476
16527
  INVALID_INPUT_CODE,
16477
16528
  str(_refused),
16478
16529
  hint=(
16479
- "The run was asked to create something inside the repository while "
16530
+ "The run was asked to modify something inside the repository while "
16480
16531
  "--no-write (ASK_READONLY=1) was in force. Drop the flag that "
16481
16532
  "persists — or point its directory option outside the repository — "
16482
16533
  "and the same answer is produced with the artefact."
sourcecode/compare.py CHANGED
@@ -40,7 +40,7 @@ COMPARE_SCHEMA: str = "candidate-comparison-v1"
40
40
  _COST_FIELDS: tuple[str, ...] = (
41
41
  "blast_radius", "affected_endpoints", "modules_spanned",
42
42
  "secured_endpoints", "transactional_boundaries", "tests_at_risk",
43
- "rollback_files",
43
+ "rollback_files", "container_wired_components",
44
44
  )
45
45
 
46
46
  # Resolutions on which D3's build_change_plan returns a bare resolution notice
@@ -108,6 +108,7 @@ def _cost_vector(plan: dict, blast: dict) -> dict[str, int]:
108
108
  "modules_spanned": modules,
109
109
  "secured_endpoints": secured,
110
110
  "transactional_boundaries": txn,
111
+ "container_wired_components": len(plan.get("container_wired") or []),
111
112
  }
112
113
 
113
114
 
@@ -151,6 +152,7 @@ def _rank_key(cand: dict) -> tuple:
151
152
  -c["transactional_boundaries"],
152
153
  -c["tests_at_risk"],
153
154
  -c["rollback_files"],
155
+ -c["container_wired_components"],
154
156
  cand["target"],
155
157
  )
156
158
 
@@ -299,6 +301,10 @@ def build_comparison(
299
301
  ),
300
302
  "tests_at_risk": "covering test symbols that reach the change",
301
303
  "rollback_files": "files the change would touch on revert",
304
+ "container_wired_components": (
305
+ "components reached by declared framework/container wiring; this is "
306
+ "separate from reverse-call fan-in"
307
+ ),
302
308
  },
303
309
  "candidates": candidates,
304
310
  "provenance": (
@@ -145,6 +145,25 @@ class Scope:
145
145
  )
146
146
  return f"at least {count} Java files{cap}, {state}"
147
147
 
148
+ def describe_for_budget(self) -> str:
149
+ """Describe scope without turning partial cache evidence into "warm".
150
+
151
+ The fast scope probe intentionally checks only the per-repository
152
+ snapshot/RIS store; probing the exact shared CIR and parse entries would
153
+ require the same analysed file set and cache key as the command about to
154
+ run. Budget advice therefore names what it did inspect and leaves the
155
+ other two layers undecided. The command payload publishes their actual
156
+ post-lookup state as ``cache_layers``.
157
+ """
158
+ if self.java_files is None:
159
+ return self.describe()
160
+ prefix = self.describe().rsplit(", ", 1)[0]
161
+ snapshot_ris = "present" if self.warm else "absent"
162
+ return (
163
+ f"{prefix}, cache layers: snapshot/RIS {snapshot_ris}; "
164
+ "context/parse unknown before analysis"
165
+ )
166
+
148
167
 
149
168
  def count_java_files(root: Path, *, cap: int = _SCAN_ENTRY_CAP) -> "tuple[Optional[int], bool]":
150
169
  """(count, exact) for the Java files under *root*. Never raises.
@@ -1363,7 +1363,7 @@ def _scan_xml_file(text: str, rel_path: str) -> list["MigrationFinding"]:
1363
1363
  title=rule.title,
1364
1364
  source_file=rel_path,
1365
1365
  first_line=first_line,
1366
- imports_found=snippets,
1366
+ evidence=snippets,
1367
1367
  explanation=explanation,
1368
1368
  fix_hint=rule.fix_hint,
1369
1369
  migration_target=rule.migration_target,
@@ -1557,7 +1557,7 @@ def _scan_dep_file(text: str, rel_path: str) -> list["MigrationFinding"]:
1557
1557
  title=rule.title,
1558
1558
  source_file=rel_path,
1559
1559
  first_line=first_line,
1560
- imports_found=[m.group(0)[:120].strip()],
1560
+ evidence=[m.group(0)[:120].strip()],
1561
1561
  explanation=rule.explanation,
1562
1562
  fix_hint=rule.fix_hint,
1563
1563
  migration_target=rule.migration_target,
@@ -1580,6 +1580,11 @@ class MigrationFinding:
1580
1580
  source_file: str
1581
1581
  first_line: int
1582
1582
  imports_found: list[str] = field(default_factory=list)
1583
+ # `imports_found` is an evidence field with a strict meaning: every entry is
1584
+ # a Java import declaration. Code, XML and build-file matches travel here
1585
+ # instead, so a consumer never has to verify a field named "imports" against
1586
+ # a class declaration or arbitrary source snippet.
1587
+ evidence: list[str] = field(default_factory=list)
1583
1588
  explanation: str = ""
1584
1589
  fix_hint: str = ""
1585
1590
  migration_target: str = ""
@@ -1620,6 +1625,8 @@ class MigrationFinding:
1620
1625
  }
1621
1626
  if self.imports_found:
1622
1627
  d["imports_found"] = self.imports_found
1628
+ if self.evidence:
1629
+ d["evidence"] = self.evidence
1623
1630
  if self.openrewrite_recipe:
1624
1631
  d["openrewrite_recipe"] = self.openrewrite_recipe
1625
1632
  else:
@@ -2049,13 +2056,14 @@ class MigrationReport:
2049
2056
  # javax.*, no Hibernate 5), readiness is N/A (None) — NOT a manufactured 100.
2050
2057
  # Absence of a migration target is "not applicable", never "100% ready".
2051
2058
  self.readiness_score = min(agg_inputs.values()) if agg_inputs else None
2059
+ applicable_names = " / ".join(agg_inputs)
2052
2060
  self.readiness_aggregate = {
2053
2061
  "method": "min",
2054
2062
  "inputs": agg_inputs,
2055
2063
  "excluded": ["jdk_modernization"],
2056
2064
  "applicable": bool(agg_inputs),
2057
2065
  "note": ("readiness_score = min over applicable migration dimensions "
2058
- "(jakarta / boot3 / hibernate). jdk_modernization is an orthogonal "
2066
+ f"({applicable_names}). jdk_modernization is an orthogonal "
2059
2067
  "upkeep axis and is intentionally excluded."
2060
2068
  if agg_inputs else
2061
2069
  "N/A — no migration target detected (no migratable javax.*, no "
@@ -2357,8 +2365,8 @@ class MigrationReport:
2357
2365
  "readiness_aggregate": self.readiness_aggregate,
2358
2366
  "readiness_note": (
2359
2367
  "readiness_score = min over applicable MIGRATION dimensions "
2360
- "(jakarta / boot3 / hibernate); see readiness_aggregate for the exact "
2361
- "inputs. N/A dimensions are excluded (never counted as 0); "
2368
+ "listed in readiness_aggregate.inputs. N/A dimensions are excluded "
2369
+ "(never counted as 0); "
2362
2370
  "jdk_modernization is orthogonal upkeep and is NOT in the aggregate. "
2363
2371
  "For decisions read the per-dimension breakdown + "
2364
2372
  "migration_blocking_count."
@@ -2450,7 +2458,7 @@ class MigrationReport:
2450
2458
  key: _finding[key]
2451
2459
  for key in (
2452
2460
  "id", "rule_id", "severity", "source_file", "first_line",
2453
- "migration_target", "imports_found", "code_context",
2461
+ "migration_target", "imports_found", "evidence", "code_context",
2454
2462
  ) if key in _finding
2455
2463
  })
2456
2464
  compact["rule_catalog"] = _rule_catalog
@@ -2782,7 +2790,8 @@ def _scan_file(
2782
2790
  title=rule.title,
2783
2791
  source_file=rel_path,
2784
2792
  first_line=first_line,
2785
- imports_found=all_matches,
2793
+ imports_found=matched_imports,
2794
+ evidence=all_matches,
2786
2795
  explanation=explanation,
2787
2796
  fix_hint=rule.fix_hint,
2788
2797
  migration_target=rule.migration_target,
sourcecode/posture.py CHANGED
@@ -187,16 +187,34 @@ class BeanResolution:
187
187
  inactive: tuple[dict, ...] = ()
188
188
  unresolved: tuple[dict, ...] = ()
189
189
  unconditional_count: int = 0
190
+ bean_definitions: tuple[tuple[str, str], ...] = ()
190
191
 
191
192
  def to_dict(self) -> dict:
193
+ conditional_count = len(self.active) + len(self.inactive) + len(self.unresolved)
194
+ population_total = conditional_count + self.unconditional_count
192
195
  return {
193
196
  "profiles": list(self.profiles),
194
197
  "summary": {
195
- "conditional_beans": len(self.active) + len(self.inactive) + len(self.unresolved),
198
+ "population": (
199
+ "Spring IoC bean definitions in analysed source; plain Java types, "
200
+ "JPA-only types, and annotations resolved outside Spring are excluded"
201
+ ),
202
+ "population_unit": "Spring IoC bean definitions",
203
+ "population_total": population_total,
204
+ "spring_container_detected": population_total > 0,
205
+ "conditional_beans": conditional_count,
206
+ "conditional_beans_unit": (
207
+ "Spring IoC bean definitions with a registration condition "
208
+ "resolved against the requested profile/property set"
209
+ ),
196
210
  "active": len(self.active),
197
211
  "inactive": len(self.inactive),
198
212
  "unresolved": len(self.unresolved),
199
213
  "unconditional": self.unconditional_count,
214
+ "unconditional_unit": (
215
+ "Spring IoC bean definitions without a registration condition; "
216
+ "they are not profile-resolved"
217
+ ),
200
218
  },
201
219
  "active": list(self.active),
202
220
  "inactive": list(self.inactive),
@@ -446,12 +464,14 @@ def resolve_beans(
446
464
  ) -> BeanResolution:
447
465
  """Resolve the conditional bean graph against an active profile set."""
448
466
  from sourcecode.security_posture import _graph_nodes
467
+ from sourcecode.spring_model import BeanGraph
449
468
  from sourcecode.spring_profiles import profile_conditional_beans
450
469
  from sourcecode.spring_properties import EffectiveProperties as _Effective
451
470
 
452
471
  active_profiles = set(profiles or set())
453
472
  resolved_properties = properties if properties is not None else _Effective()
454
473
  conditional = {b.symbol: b for b in profile_conditional_beans(cir)}
474
+ spring_beans = BeanGraph.build(cir).spring_beans()
455
475
 
456
476
  active: list[dict] = []
457
477
  inactive: list[dict] = []
@@ -463,14 +483,7 @@ def resolve_beans(
463
483
 
464
484
  for node in _nodes:
465
485
  fqn = str(node.get("fqn") or "")
466
- if not fqn or "#" in fqn:
467
- continue # members follow their declaring type
468
- # F-2: an annotation type is not a bean. `@interface FooCondition` carries
469
- # `@Conditional(...)` because it DEFINES the condition — counting it as a
470
- # conditionally-registered component made the census 77 where the compiler
471
- # sees 76, and put the definition in `unresolved` beside the beans that use
472
- # it. Spring registers components, not annotation declarations.
473
- if str(node.get("symbol_kind") or "") == "annotation":
486
+ if fqn not in spring_beans:
474
487
  continue
475
488
  verdicts = _condition_verdicts(
476
489
  node, conditional.get(fqn), active_profiles, resolved_properties,
@@ -522,6 +535,9 @@ def resolve_beans(
522
535
  inactive=tuple(sorted(inactive, key=key)),
523
536
  unresolved=tuple(sorted(unresolved, key=key)),
524
537
  unconditional_count=unconditional,
538
+ bean_definitions=tuple(
539
+ sorted((fqn, bean.source_file) for fqn, bean in spring_beans.items())
540
+ ),
525
541
  )
526
542
 
527
543
 
@@ -711,12 +727,39 @@ def security_projection(cir: "CanonicalRepositoryIR", resolution: BeanResolution
711
727
  active = _slice(resolution.active)
712
728
  inactive = _slice(resolution.inactive)
713
729
  unresolved = _slice(resolution.unresolved)
730
+ conditional_symbols = {
731
+ entry["symbol"]
732
+ for entry in (*resolution.active, *resolution.inactive, *resolution.unresolved)
733
+ }
734
+ unconditional = [
735
+ {
736
+ "symbol": fqn,
737
+ "source_file": source_file,
738
+ }
739
+ for fqn, source_file in resolution.bean_definitions
740
+ if fqn not in conditional_symbols
741
+ and (node := by_fqn.get(fqn)) is not None
742
+ and _is_security_relevant(node, cir)
743
+ ]
744
+ unconditional.sort(key=lambda entry: entry["symbol"])
714
745
  return {
746
+ "population": (
747
+ "Spring IoC bean definitions that configure request security; "
748
+ "conditional entries are profile/property-resolved and unconditional "
749
+ "entries are reported separately"
750
+ ),
751
+ "population_unit": "Spring security bean definitions",
715
752
  "active": active,
716
753
  "inactive": inactive,
717
754
  "unresolved": unresolved,
755
+ "unconditional": unconditional,
718
756
  "summary": {
719
757
  "active": len(active), "inactive": len(inactive), "unresolved": len(unresolved),
758
+ "unconditional": len(unconditional),
759
+ "unconditional_unit": (
760
+ "Spring security bean definitions without a registration condition; "
761
+ "they are not profile-resolved"
762
+ ),
720
763
  },
721
764
  }
722
765