sourcecode 4.8.0__py3-none-any.whl → 4.9.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of sourcecode might be problematic. Click here for more details.

sourcecode/__init__.py 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__ = "4.8.0"
7
+ __version__ = "4.9.1"
@@ -0,0 +1,158 @@
1
+ """Signed audit report over existing ASK evidence.
2
+
3
+ This module packages facts other commands already publish. It does not score ROI,
4
+ choose a fix, or introduce a second judgement layer: the report summary is a
5
+ reader-friendly index over the embedded evidence blocks.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import hmac
12
+ import json
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+ from typing import Any, Optional
16
+
17
+ from sourcecode import __version__
18
+
19
+ AUDIT_REPORT_SCHEMA = "audit-report-v1"
20
+
21
+
22
+ def _canonical_json(data: dict[str, Any]) -> bytes:
23
+ return json.dumps(data, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
24
+
25
+
26
+ def _sign(payload: dict[str, Any], key: bytes) -> dict[str, str]:
27
+ canonical = _canonical_json(payload)
28
+ return {
29
+ "algorithm": "hmac-sha256",
30
+ "payload_sha256": hashlib.sha256(canonical).hexdigest(),
31
+ "key_id": hashlib.sha256(key).hexdigest()[:12],
32
+ "signature": hmac.new(key, canonical, hashlib.sha256).hexdigest(),
33
+ }
34
+
35
+
36
+ def build_audit_report(
37
+ root: Path,
38
+ *,
39
+ profiles: Optional[set[str]] = None,
40
+ sign_key: Optional[bytes] = None,
41
+ risk_limit: int = 10,
42
+ ) -> dict[str, Any]:
43
+ """Build the buyer-readable audit bundle from existing command payloads."""
44
+ from sourcecode.posture import build_posture
45
+ from sourcecode.risk import build_risk
46
+
47
+ root = Path(root).resolve()
48
+ risk = build_risk(root, limit=risk_limit, min_band="low", profiles=profiles)
49
+ posture = build_posture(root, profiles or set())
50
+ top_risks = risk.get("risks", [])[:risk_limit]
51
+ access = ((posture.get("endpoints") or {}).get("effective_access") or {})
52
+
53
+ unsigned: dict[str, Any] = {
54
+ "schema_version": AUDIT_REPORT_SCHEMA,
55
+ "tool": {"name": "ask", "version": __version__},
56
+ "generated_at": datetime.now(timezone.utc).isoformat(),
57
+ "repository": {"path": str(root)},
58
+ **({"profile_set": sorted(profiles)} if profiles else {}),
59
+ "summary": {
60
+ "risk_model": risk.get("model"),
61
+ "total_defects": risk.get("total_defects"),
62
+ "total_findings": risk.get("total_findings"),
63
+ "risk_bands": risk.get("by_band", {}),
64
+ "top_risks_shown": len(top_risks),
65
+ "endpoint_access": access.get("summary", {}),
66
+ "unresolved_beans": len(posture.get("unresolved") or []),
67
+ },
68
+ "top_risks": top_risks,
69
+ "evidence": {
70
+ "risk": risk,
71
+ "posture": posture,
72
+ },
73
+ "limits": {
74
+ "statement": (
75
+ "This report packages existing ASK evidence. It does not certify "
76
+ "compliance, choose fixes, assign ROI, or replace a human audit decision."
77
+ ),
78
+ "non_coverage": (risk.get("non_coverage") or {}).get("items", []),
79
+ },
80
+ }
81
+ from sourcecode.provenance import build_evidence_manifest
82
+ unsigned["evidence_manifest"] = build_evidence_manifest(
83
+ unsigned,
84
+ command="audit-report",
85
+ root=root,
86
+ inputs={
87
+ "profiles": sorted(profiles) if profiles else None,
88
+ "risk_limit": risk_limit,
89
+ "signed": bool(sign_key),
90
+ },
91
+ )
92
+ # Sign and publish the same JSON-normalized material. Some embedded evidence
93
+ # may contain tuples or other JSON-coercible values; normalizing before signing
94
+ # avoids a signature over an in-memory shape the user never receives.
95
+ normalized = json.loads(_canonical_json(unsigned).decode("utf-8"))
96
+ if sign_key:
97
+ normalized["signature"] = _sign(normalized, sign_key)
98
+ return normalized
99
+
100
+
101
+ def render_markdown(report: dict[str, Any]) -> str:
102
+ """Human report renderer over the JSON contract."""
103
+ summary = report.get("summary") or {}
104
+ repo = (report.get("repository") or {}).get("path", "")
105
+ profile = ", ".join(report.get("profile_set") or []) or "default"
106
+ lines = [
107
+ "# ASK Audit Report",
108
+ "",
109
+ f"- Repository: `{repo}`",
110
+ f"- ASK version: `{(report.get('tool') or {}).get('version', '')}`",
111
+ f"- Profile set: `{profile}`",
112
+ f"- Total defects: `{summary.get('total_defects')}`",
113
+ f"- Risk bands: `{summary.get('risk_bands')}`",
114
+ f"- Endpoint access: `{summary.get('endpoint_access')}`",
115
+ "",
116
+ "## Top Risks",
117
+ "",
118
+ ]
119
+ risks = report.get("top_risks") or []
120
+ if not risks:
121
+ lines.append("No composed risks were reported.")
122
+ else:
123
+ lines.extend([
124
+ "| Band | Score | Rules | File | Line | Title |",
125
+ "|---|---:|---|---|---:|---|",
126
+ ])
127
+ for row in risks:
128
+ rules = ", ".join(str(r) for r in row.get("rule_ids", []))
129
+ lines.append(
130
+ "| "
131
+ + " | ".join([
132
+ str(row.get("band", "")),
133
+ str(row.get("severity_effective", "")),
134
+ rules,
135
+ str(row.get("source_file", "")),
136
+ str(row.get("first_line", "")),
137
+ str(row.get("title", "")).replace("|", "\\|"),
138
+ ])
139
+ + " |"
140
+ )
141
+ lines.extend([
142
+ "",
143
+ "## Limits",
144
+ "",
145
+ str((report.get("limits") or {}).get("statement", "")),
146
+ ])
147
+ signature = report.get("signature")
148
+ if isinstance(signature, dict):
149
+ lines.extend([
150
+ "",
151
+ "## Signature",
152
+ "",
153
+ f"- Algorithm: `{signature.get('algorithm')}`",
154
+ f"- Payload SHA-256: `{signature.get('payload_sha256')}`",
155
+ f"- Key ID: `{signature.get('key_id')}`",
156
+ f"- Signature: `{signature.get('signature')}`",
157
+ ])
158
+ return "\n".join(lines) + "\n"
sourcecode/cli.py CHANGED
@@ -184,7 +184,8 @@ COMMAND_TIERS: "tuple[tuple[str, str, tuple[str, ...]], ...]" = (
184
184
  "cache", "auth", "mcp", "telemetry",
185
185
  )),
186
186
  ("experimental", "shape may change in a minor — do not gate CI on it", (
187
- "risk", "enrich", "data-exposure", "migrate-recipe", "posture", "archetype",
187
+ "risk", "enrich", "audit-report", "data-exposure", "migrate-recipe",
188
+ "posture", "archetype",
188
189
  )),
189
190
  # `retrieve` publishes 15+ intents whose answers the other commands already
190
191
  # give better: measured on the battery, `security-surface` merely re-states
@@ -332,7 +333,7 @@ def _build_help_text() -> str:
332
333
  plan_badge = "[yellow]Free[/yellow] · [dim]ask activate <key>[/dim] to unlock Pro"
333
334
 
334
335
  text = f"""\
335
- [bold]ASK Engine[/bold] [dim]· CLI: ask[/dim] {plan_badge}
336
+ [bold]ASK Engine {__version__}[/bold] [dim]· CLI: ask[/dim] {plan_badge}
336
337
 
337
338
  Deterministic Java/Spring semantics and reusable structural context for AI coding agents.
338
339
 
@@ -344,7 +345,7 @@ of files) in minutes. Semantic analysis itself is sub-second; repo indexing domi
344
345
  posture . --diff dev:prod [dim]# effective access, two profile sets (exp.)[/dim]
345
346
  endpoints . [dim]# endpoints + effective path + policy[/dim]
346
347
  spring-audit . [dim]# TX anomalies + security surface[/dim]
347
- migrate-check . --compact [dim]# Boot 2→3: located blockers + effort[/dim]
348
+ migrate-check . --compact [dim]# Boot 2→3 + Java LTS/licensing inventory[/dim]
348
349
  risk . [dim]# defects ranked by reach × access (exp.)[/dim]
349
350
 
350
351
  {_remedy_help_block()}
@@ -359,6 +360,7 @@ of files) in minutes. Semantic analysis itself is sub-second; repo indexing domi
359
360
  impact <Class> . [dim]# reverse deps → endpoints reached[/dim]
360
361
  pr-impact . --files - [dim]# same, scoped to a diff on stdin; gating codes[/dim]
361
362
  posture . --since main [dim]# did this branch open an endpoint? (exp.)[/dim]
363
+ audit-report . --format markdown [dim]# signed human evidence bundle (exp.)[/dim]
362
364
  verify . [dim]# contract gate, baseline-relative[/dim]
363
365
  verify-edit . [dim]# did working-tree edits change behaviour?[/dim]
364
366
  [dim]modernize · explain <Class> · validation · export · repo-ir[/dim]
@@ -3873,6 +3875,11 @@ def prepare_context_cmd(
3873
3875
  ask prepare-context review-pr . --since main --output review.json
3874
3876
  ask prepare-context onboard --llm-prompt
3875
3877
  ask prepare-context --task-help
3878
+
3879
+ \b
3880
+ Provenance:
3881
+ JSON output includes evidence_manifest: claim IDs for context files and
3882
+ source-backed rows, plus command inputs, ASK version, git HEAD and freshness.
3876
3883
  """
3877
3884
  from sourcecode.prepare_context import TASKS, TaskContextBuilder
3878
3885
 
@@ -4417,6 +4424,20 @@ def prepare_context_cmd(
4417
4424
  _skipped.append("test_gap_discovery")
4418
4425
  out["skipped_analyzers"] = _skipped
4419
4426
 
4427
+ from sourcecode.provenance import build_evidence_manifest as _build_evidence_manifest
4428
+ out["evidence_manifest"] = _build_evidence_manifest(
4429
+ out,
4430
+ command="prepare-context",
4431
+ root=target,
4432
+ inputs={
4433
+ "task": task,
4434
+ "since": since,
4435
+ "format": format or "json",
4436
+ "fast": fast,
4437
+ "llm_prompt": llm_prompt,
4438
+ },
4439
+ )
4440
+
4420
4441
  # P0-1: Apply output budget per task — safety net for large repos.
4421
4442
  _pc_budget = _prepare_context_budget(task)
4422
4443
 
@@ -6908,6 +6929,73 @@ def _render_risk_table(
6908
6929
  return "\n".join(lines)
6909
6930
 
6910
6931
 
6932
+ def _render_enrich_table(
6933
+ data: dict[str, Any],
6934
+ *,
6935
+ rule_filter: Optional[str],
6936
+ band_filter: Optional[str],
6937
+ top_n: int,
6938
+ ) -> str:
6939
+ """Render SARIF-enriched findings as a bounded human triage table."""
6940
+ rule = (rule_filter or "").strip().upper()
6941
+ band = (band_filter or "").strip().lower()
6942
+ rows = []
6943
+ for row in data.get("findings", []):
6944
+ if not isinstance(row, dict):
6945
+ continue
6946
+ external = row.get("external", {}) if isinstance(row.get("external"), dict) else {}
6947
+ rule_id = str(external.get("rule_id") or ",".join(str(r) for r in row.get("rule_ids", [])))
6948
+ if rule and rule != rule_id.upper():
6949
+ continue
6950
+ if band and str(row.get("band", "")).lower() != band:
6951
+ continue
6952
+ rows.append(row)
6953
+ total = len(rows)
6954
+ if top_n > 0:
6955
+ rows = rows[:top_n]
6956
+
6957
+ def _cell(value: object, width: int) -> str:
6958
+ text = "" if value is None else str(value)
6959
+ text = text.replace("\n", " ")
6960
+ if len(text) > width:
6961
+ text = text[: max(0, width - 1)] + "..."
6962
+ return text
6963
+
6964
+ lines = [
6965
+ (
6966
+ f"Enriched findings table: showing {len(rows)}/{total} finding(s)"
6967
+ f"{' (rule=' + rule + ')' if rule else ''}"
6968
+ f"{' (band=' + band + ')' if band else ''}"
6969
+ f"{' (top-n=' + str(top_n) + ')' if top_n > 0 else ''}"
6970
+ )
6971
+ ]
6972
+ if not rows:
6973
+ lines.append("No matching enriched findings.")
6974
+ return "\n".join(lines)
6975
+
6976
+ headers = ("band", "score", "tool", "rule", "reach", "auth", "file", "line", "title")
6977
+ widths = (8, 7, 12, 18, 7, 14, 34, 6, 48)
6978
+ lines.append(" | ".join(_cell(h, w).ljust(w) for h, w in zip(headers, widths)))
6979
+ lines.append("-+-".join("-" * w for w in widths))
6980
+ for row in rows:
6981
+ factors = row.get("factors", {}) if isinstance(row.get("factors"), dict) else {}
6982
+ auth = factors.get("auth_verdict", {}) if isinstance(factors.get("auth_verdict"), dict) else {}
6983
+ external = row.get("external", {}) if isinstance(row.get("external"), dict) else {}
6984
+ values = (
6985
+ row.get("band", ""),
6986
+ row.get("severity_effective", ""),
6987
+ external.get("tool", ""),
6988
+ external.get("rule_id", ""),
6989
+ row.get("endpoints_reached", ""),
6990
+ auth.get("value", ""),
6991
+ row.get("source_file", ""),
6992
+ row.get("first_line", "-"),
6993
+ row.get("title", ""),
6994
+ )
6995
+ lines.append(" | ".join(_cell(v, w).ljust(w) for v, w in zip(values, widths)))
6996
+ return "\n".join(lines)
6997
+
6998
+
6911
6999
  def _render_migrate_check_table(
6912
7000
  data: dict[str, Any],
6913
7001
  *,
@@ -7668,9 +7756,10 @@ def risk_cmd(
7668
7756
  it, and every row publishes every factor with the authority each came from, so
7669
7757
  a reader can disagree with one and keep the rest. An axis that could not be
7670
7758
  measured is `unknown`, multiplies by 1.0, and is named in `blind_axes`. The
7671
- two input-path axes — a query built by concatenation, a route accepting an
7672
- unconstrained body — are adjacencies: no dataflow is followed (NC-001, in the
7673
- payload's `non_coverage`).
7759
+ input-path axes are bounded: a query may be merely concatenated, or may carry
7760
+ an annotated HTTP input through positional calls to a known query sink; a
7761
+ reached route may accept an unconstrained body. General taint is still NC-001,
7762
+ in the payload's `non_coverage`.
7674
7763
 
7675
7764
  \b
7676
7765
  One row is one **defect** — the same identity `spring-audit` groups by, so the
@@ -7758,6 +7847,28 @@ def enrich_cmd(
7758
7847
  limit: int = typer.Option(
7759
7848
  50, "--limit", help="How many enriched findings to publish (highest first)."
7760
7849
  ),
7850
+ table: bool = typer.Option(
7851
+ False,
7852
+ "--table",
7853
+ help="Render a human table instead of JSON/YAML. Use with --rule, --band and --top-n.",
7854
+ ),
7855
+ rule_filter: Optional[str] = typer.Option(
7856
+ None,
7857
+ "--rule",
7858
+ "--only",
7859
+ help="With --table, show only enriched findings from one external rule id.",
7860
+ ),
7861
+ band_filter: Optional[str] = typer.Option(
7862
+ None,
7863
+ "--band",
7864
+ help="With --table, show only one composed band: critical | high | medium | low.",
7865
+ ),
7866
+ top_n: int = typer.Option(
7867
+ 0,
7868
+ "--top-n",
7869
+ min=0,
7870
+ help="With --table, show only the top N findings after filtering. 0 means all.",
7871
+ ),
7761
7872
  min_band: str = typer.Option(
7762
7873
  "low",
7763
7874
  "--min-band",
@@ -7779,9 +7890,10 @@ def enrich_cmd(
7779
7890
 
7780
7891
  \b
7781
7892
  This product does not detect injection, CVEs or secrets, and says so in every
7782
- security payload (NC-001, NC-002). A scanner that does has no bean graph, no
7783
- profile model and no endpoint id it shares with anything, so its output is a
7784
- list nobody can order. `enrich` reads that list and answers the question the
7893
+ security payload (NC-001, NC-002). `risk` contributes one bounded HTTP-input
7894
+ to query-sink factor, not a scanner. A scanner has no bean graph, no profile
7895
+ model and no endpoint id it shares with anything, so its output is a list
7896
+ nobody can order. `enrich` reads that list and answers the question the
7785
7897
  scanner cannot: which of these findings sits in code an unauthenticated
7786
7898
  request reaches.
7787
7899
 
@@ -7813,6 +7925,7 @@ def enrich_cmd(
7813
7925
  ask enrich . --sarif semgrep.sarif
7814
7926
  ask enrich . --sarif codeql.sarif --min-band high
7815
7927
  ask enrich . --sarif trivy.sarif --profile prod
7928
+ ask enrich . --sarif semgrep.sarif --table --rule java-sqli --band high --top-n 20
7816
7929
  ask enrich /path/to/repo --sarif trivy.sarif -o enriched.json
7817
7930
  """
7818
7931
  from sourcecode.sarif import enrich_sarif
@@ -7835,6 +7948,16 @@ def enrich_cmd(
7835
7948
  expected="critical|high|medium|low",
7836
7949
  )
7837
7950
  raise typer.Exit(code=1)
7951
+ if band_filter is not None:
7952
+ band_filter = band_filter.strip().lower()
7953
+ if band_filter is not None and band_filter not in ("critical", "high", "medium", "low"):
7954
+ _emit_error_json(
7955
+ INVALID_INPUT_CODE,
7956
+ f"--band expects critical | high | medium | low (got {band_filter!r}).",
7957
+ hint="Example: --table --band high",
7958
+ expected="critical|high|medium|low",
7959
+ )
7960
+ raise typer.Exit(code=1)
7838
7961
  if not Path(sarif).is_file():
7839
7962
  _emit_error_json(
7840
7963
  INVALID_INPUT_CODE,
@@ -7844,11 +7967,12 @@ def enrich_cmd(
7844
7967
  )
7845
7968
  raise typer.Exit(code=1)
7846
7969
 
7970
+ enrich_limit = 100000 if table and (rule_filter or band_filter or top_n) else limit
7847
7971
  _prog = Progress()
7848
7972
  _prog.start("composing risk factors")
7849
7973
  try:
7850
7974
  data = enrich_sarif(
7851
- Path(sarif), path, limit=limit, min_band=min_band,
7975
+ Path(sarif), path, limit=enrich_limit, min_band=min_band,
7852
7976
  profiles=_profile_set(profile),
7853
7977
  )
7854
7978
  except (ValueError, json.JSONDecodeError) as exc:
@@ -7863,8 +7987,14 @@ def enrich_cmd(
7863
7987
  finally:
7864
7988
  _prog.stop()
7865
7989
 
7990
+ if table:
7991
+ output = _render_enrich_table(
7992
+ data, rule_filter=rule_filter, band_filter=band_filter, top_n=top_n
7993
+ )
7994
+ else:
7995
+ output = _serialize_dict(data, format)
7866
7996
  _emit_command_output(
7867
- _serialize_dict(data, format),
7997
+ output,
7868
7998
  output_path,
7869
7999
  copy,
7870
8000
  success_msg=(
@@ -7874,6 +8004,103 @@ def enrich_cmd(
7874
8004
  )
7875
8005
 
7876
8006
 
8007
+ @app.command("audit-report")
8008
+ def audit_report_cmd(
8009
+ path: Path = typer.Argument(
8010
+ Path("."),
8011
+ help="Repository path (default: current directory).",
8012
+ ),
8013
+ profile: Optional[str] = typer.Option(
8014
+ None,
8015
+ "--profile",
8016
+ help="Active profile set, comma-separated (e.g. prod or prod,metrics).",
8017
+ ),
8018
+ sign_key: Optional[Path] = typer.Option(
8019
+ None,
8020
+ "--sign-key",
8021
+ help="Sign the canonical JSON report with this local HMAC key file.",
8022
+ ),
8023
+ output_path: Optional[Path] = typer.Option(
8024
+ None, "--output", "-o", help="Write the report to a file instead of stdout."
8025
+ ),
8026
+ format: str = typer.Option(
8027
+ "json", "--format", "-f", help="Output format: json, yaml or markdown."
8028
+ ),
8029
+ copy: bool = _copy_option(),
8030
+ ) -> None:
8031
+ """[EXPERIMENTAL] Human audit bundle over existing ASK evidence, optionally signed.
8032
+
8033
+ \b
8034
+ This packages the same evidence `risk` and `posture` already publish into a
8035
+ report a buyer can read: top risks, effective runtime posture, source-linked
8036
+ evidence and non-coverage. It does not certify compliance, choose fixes or
8037
+ assign ROI. JSON includes evidence_manifest: claim IDs mapped to source spans,
8038
+ command inputs, ASK version and freshness.
8039
+
8040
+ \b
8041
+ `--sign-key` adds an HMAC-SHA256 signature over the canonical JSON payload.
8042
+ The key is local and explicit; no network trust service or new PKI is implied.
8043
+
8044
+ \b
8045
+ Examples:
8046
+ ask audit-report .
8047
+ ask audit-report . --profile prod --format markdown
8048
+ ask audit-report . --sign-key audit.key -o audit-report.json
8049
+ """
8050
+ from sourcecode.audit_report import build_audit_report, render_markdown
8051
+
8052
+ path = _admit_path(path)
8053
+ fmt = format.strip().lower()
8054
+ if fmt not in ("json", "yaml", "markdown"):
8055
+ _emit_error_json(
8056
+ INVALID_INPUT_CODE,
8057
+ f"--format expects json | yaml | markdown (got {format!r}).",
8058
+ hint="Example: ask audit-report . --format markdown",
8059
+ expected="json|yaml|markdown",
8060
+ )
8061
+ raise typer.Exit(code=1)
8062
+ key_bytes = None
8063
+ if sign_key is not None:
8064
+ key_path = Path(sign_key).expanduser()
8065
+ if not key_path.is_file():
8066
+ _emit_error_json(
8067
+ INVALID_INPUT_CODE,
8068
+ f"--sign-key must name a readable file (got {str(sign_key)!r}).",
8069
+ hint="Example: ask audit-report . --sign-key audit.key",
8070
+ expected="an existing key file",
8071
+ )
8072
+ raise typer.Exit(code=1)
8073
+ key_bytes = key_path.read_bytes()
8074
+ if not key_bytes:
8075
+ _emit_error_json(
8076
+ INVALID_INPUT_CODE,
8077
+ "--sign-key file is empty; refusing to publish an unverifiable signature.",
8078
+ hint="Write a random local secret to the key file first.",
8079
+ expected="a non-empty key file",
8080
+ )
8081
+ raise typer.Exit(code=1)
8082
+
8083
+ _prog = Progress()
8084
+ _prog.start("packaging audit evidence")
8085
+ try:
8086
+ data = build_audit_report(
8087
+ path, profiles=_profile_set(profile), sign_key=key_bytes,
8088
+ )
8089
+ finally:
8090
+ _prog.stop()
8091
+ output = render_markdown(data) if fmt == "markdown" else _serialize_dict(data, fmt)
8092
+ _emit_command_output(
8093
+ output,
8094
+ output_path,
8095
+ copy,
8096
+ success_msg=f"audit report written to {output_path}",
8097
+ # A signed artifact cannot be mutated after signing. The report carries
8098
+ # tool/version metadata itself, and `signature.payload_sha256` is over
8099
+ # the exact JSON payload the user receives.
8100
+ stamp_envelope=False,
8101
+ )
8102
+
8103
+
7877
8104
  @app.command("migrate-recipe")
7878
8105
  def migrate_recipe_cmd(
7879
8106
  path: Path = typer.Argument(
@@ -8145,6 +8372,14 @@ def posture_cmd(
8145
8372
  "by what it leaves unauthenticated."
8146
8373
  ),
8147
8374
  ),
8375
+ env_file: Optional[Path] = typer.Option(
8376
+ None,
8377
+ "--env-file",
8378
+ help=(
8379
+ "With --resolve-environments, read SPRING_PROFILES_ACTIVE or "
8380
+ "spring.profiles.active from an explicit deployment env/dotenv file."
8381
+ ),
8382
+ ),
8148
8383
  property_overrides: Optional[list[str]] = typer.Option(
8149
8384
  None,
8150
8385
  "--property",
@@ -8192,6 +8427,7 @@ def posture_cmd(
8192
8427
  ask posture . --since origin/main --fail-on opened
8193
8428
  ask posture . --profile prod --property app.security.enabled=true
8194
8429
  ask posture . --resolve-environments
8430
+ ask posture . --resolve-environments --env-file prod.env
8195
8431
  """
8196
8432
  from sourcecode.posture import (
8197
8433
  FAIL_ON_CHOICES,
@@ -8266,6 +8502,14 @@ def posture_cmd(
8266
8502
  hint="ask posture . --profile prod --diff-ref origin/main:HEAD",
8267
8503
  )
8268
8504
  raise typer.Exit(code=1)
8505
+ if env_file and not resolve_environments_flag:
8506
+ _emit_error_json(
8507
+ INVALID_INPUT_CODE,
8508
+ "--env-file supplies deployment evidence for --resolve-environments; it cannot "
8509
+ "be used with a single profile, profile diff or ref comparison.",
8510
+ hint="ask posture . --resolve-environments --env-file prod.env",
8511
+ )
8512
+ raise typer.Exit(code=1)
8269
8513
 
8270
8514
  # One spinner over the whole dispatch: every branch below resolves the
8271
8515
  # conditional bean graph, and `--diff-ref` resolves it twice (once per ref).
@@ -8280,10 +8524,26 @@ def posture_cmd(
8280
8524
  INVALID_INPUT_CODE,
8281
8525
  "--resolve-environments answers which profile set runs; it cannot be "
8282
8526
  "combined with --profile, --diff or --diff-ref/--since, which state one.",
8283
- hint="Run `ask posture . --resolve-environments` on its own.",
8527
+ hint="Run `ask posture . --resolve-environments` on its own, or add "
8528
+ "`--env-file prod.env` as deployment evidence.",
8284
8529
  )
8285
8530
  raise typer.Exit(code=1)
8286
- data = resolve_environments(path, overrides)
8531
+ deployment_signals = None
8532
+ if env_file is not None:
8533
+ from sourcecode.environment_resolution import collect_env_file_signals
8534
+
8535
+ env_path = Path(env_file).expanduser()
8536
+ if not env_path.is_file():
8537
+ _emit_error_json(
8538
+ INVALID_INPUT_CODE,
8539
+ f"--env-file must name a readable file (got {str(env_file)!r}).",
8540
+ hint="ask posture . --resolve-environments --env-file prod.env",
8541
+ )
8542
+ raise typer.Exit(code=1)
8543
+ deployment_signals = collect_env_file_signals(env_path, root=path)
8544
+ data = resolve_environments(
8545
+ path, overrides, deployment_signals=deployment_signals,
8546
+ )
8287
8547
  elif ref_comparison:
8288
8548
  from sourcecode.git_checkout import RefError
8289
8549
 
@@ -8484,9 +8744,9 @@ def migrate_check_cmd(
8484
8744
  None,
8485
8745
  "--target-jdk",
8486
8746
  help=(
8487
- "The JDK you are actually moving to (e.g. 11, 17, 21). Rules for a "
8488
- "later JDK are not your blockers and are not reported. Without it, "
8489
- "every JDK axis is reported at once."
8747
+ "The JDK you are actually moving to (e.g. 11, 17, 21, 25). Rules "
8748
+ "for a later JDK are not your blockers and are not reported. Also "
8749
+ "sets java_lts_inventory.target_lts."
8490
8750
  ),
8491
8751
  ),
8492
8752
  keep_boot: bool = typer.Option(
@@ -8546,7 +8806,7 @@ def migrate_check_cmd(
8546
8806
  help="Caller BFS depth for --blast-radius (1-8, default: 4).",
8547
8807
  ),
8548
8808
  ) -> None:
8549
- """Spring Boot 2→3 migration readiness: detect javax→jakarta namespace blockers.
8809
+ """Spring Boot 2→3 migration readiness + Java LTS/licensing inventory.
8550
8810
 
8551
8811
  \b
8552
8812
  Detects:
@@ -8575,6 +8835,7 @@ def migrate_check_cmd(
8575
8835
  ask migrate-check . --compact bounded decision summary
8576
8836
  ask migrate-check /path/to/repo --format text
8577
8837
  ask migrate-check . --min-severity high
8838
+ ask migrate-check . --target-jdk 25 Java LTS target inventory
8578
8839
  ask migrate-check . --table --rule MIG-001 --band critical --top-n 20
8579
8840
  ask migrate-check . --output migration.json
8580
8841
  ask migrate-check . --snapshot --ref sprint-12 persist a readiness point
@@ -8597,6 +8858,13 @@ def migrate_check_cmd(
8597
8858
  <repo>/.ask/readiness-history). --trend reads that series and reports
8598
8859
  first→last movement — no improving/degrading label, and readiness_score is
8599
8860
  flagged not-comparable when the applicable dimension set changed.
8861
+
8862
+ \b
8863
+ Java LTS/licensing inventory:
8864
+ The JSON report includes java_lts_inventory: Java 8/11/17/21/25 evidence
8865
+ from Maven/Gradle, local runtime hints and container files; target-LTS
8866
+ blockers; and explicit Oracle JDK/licensing-review signals. This is
8867
+ inventory evidence, not legal advice, and it never changes readiness_score.
8600
8868
  """
8601
8869
  from sourcecode.repository_ir import find_java_files
8602
8870
  from sourcecode.migrate_check import run_migrate_check
@@ -11913,7 +12181,8 @@ HELP_PANELS: "tuple[tuple[str, tuple[str, ...]], ...]" = (
11913
12181
  "cache", "auth", "mcp", "telemetry", "baseline",
11914
12182
  )),
11915
12183
  ("Experimental — shape may change", (
11916
- "risk", "enrich", "data-exposure", "migrate-recipe", "archetype", "retrieve",
12184
+ "risk", "enrich", "audit-report", "data-exposure", "migrate-recipe",
12185
+ "archetype", "retrieve",
11917
12186
  )),
11918
12187
  )
11919
12188