sourcecode 4.9.0__py3-none-any.whl → 4.10.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.

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.9.0"
7
+ __version__ = "4.10.0"
@@ -39,13 +39,19 @@ def build_audit_report(
39
39
  profiles: Optional[set[str]] = None,
40
40
  sign_key: Optional[bytes] = None,
41
41
  risk_limit: int = 10,
42
+ risk_payload: Optional[dict[str, Any]] = None,
42
43
  ) -> dict[str, Any]:
43
44
  """Build the buyer-readable audit bundle from existing command payloads."""
44
45
  from sourcecode.posture import build_posture
45
- from sourcecode.risk import build_risk
46
46
 
47
47
  root = Path(root).resolve()
48
- risk = build_risk(root, limit=risk_limit, min_band="low", profiles=profiles)
48
+ if risk_payload is None:
49
+ from sourcecode.risk import build_risk
50
+ risk = build_risk(root, limit=risk_limit, min_band="low", profiles=profiles)
51
+ risk_source = "computed"
52
+ else:
53
+ risk = risk_payload
54
+ risk_source = "from-risk"
49
55
  posture = build_posture(root, profiles or set())
50
56
  top_risks = risk.get("risks", [])[:risk_limit]
51
57
  access = ((posture.get("endpoints") or {}).get("effective_access") or {})
@@ -58,6 +64,7 @@ def build_audit_report(
58
64
  **({"profile_set": sorted(profiles)} if profiles else {}),
59
65
  "summary": {
60
66
  "risk_model": risk.get("model"),
67
+ "risk_source": risk_source,
61
68
  "total_defects": risk.get("total_defects"),
62
69
  "total_findings": risk.get("total_findings"),
63
70
  "risk_bands": risk.get("by_band", {}),
@@ -78,6 +85,18 @@ def build_audit_report(
78
85
  "non_coverage": (risk.get("non_coverage") or {}).get("items", []),
79
86
  },
80
87
  }
88
+ from sourcecode.provenance import build_evidence_manifest
89
+ unsigned["evidence_manifest"] = build_evidence_manifest(
90
+ unsigned,
91
+ command="audit-report",
92
+ root=root,
93
+ inputs={
94
+ "profiles": sorted(profiles) if profiles else None,
95
+ "risk_limit": risk_limit,
96
+ "risk_source": risk_source,
97
+ "signed": bool(sign_key),
98
+ },
99
+ )
81
100
  # Sign and publish the same JSON-normalized material. Some embedded evidence
82
101
  # may contain tuples or other JSON-coercible values; normalizing before signing
83
102
  # avoids a signature over an in-memory shape the user never receives.
sourcecode/cache_model.py CHANGED
@@ -144,6 +144,12 @@ COMMANDS: tuple[CommandCache, ...] = (
144
144
  "repository side, so what it buys is what it buys `risk`.",
145
145
  "not measured on the battery yet — bounded by the `risk` composition, plus "
146
146
  "reading one JSON file"),
147
+ CommandCache("audit-report", ("cir", "parse"), "shared", False,
148
+ "Packages `risk` and `posture` evidence. A warm helps the repository side; "
149
+ "`--from-risk risk.json` skips risk recomputation entirely and only builds "
150
+ "the posture/report projection.",
151
+ "not measured on the battery yet — bounded by `risk` + `posture`, or by "
152
+ "`posture` when `--from-risk` is supplied"),
147
153
  CommandCache("migrate-recipe", ("parse",), "shared", False,
148
154
  "Runs the same scan as `migrate-check` and projects its findings into an "
149
155
  "OpenRewrite recipe, so it buys exactly what a warm buys `migrate-check`: "
@@ -43,6 +43,23 @@ _REVERSE_EXCLUDE: frozenset[str] = frozenset({"annotated_with", "mapped_to"})
43
43
  # the CIR endpoint count reconciles with the `endpoints` command). See BUG #7.
44
44
  _FQN_PATH_RE: re.Pattern = re.compile(r"/(org|com|net|io|edu)\.[a-z][a-z0-9]*\.[a-zA-Z]")
45
45
 
46
+ _JAVA_RESERVED_HANDLER_NAMES: frozenset[str] = frozenset({
47
+ "if", "else", "for", "while", "do", "switch", "case", "break", "continue",
48
+ "return", "new", "throw", "try", "catch", "finally", "instanceof",
49
+ "this", "super", "void", "class", "interface", "enum", "extends", "implements",
50
+ "import", "package", "static", "final", "abstract", "synchronized", "native",
51
+ "true", "false", "null",
52
+ })
53
+
54
+
55
+ def _route_has_reserved_handler(route: dict) -> bool:
56
+ """Reject parser-corrupt endpoint symbols such as ``pkg.Controller#if``."""
57
+ symbol = str(route.get("symbol") or "")
58
+ if "#" not in symbol:
59
+ return False
60
+ handler = symbol.rsplit("#", 1)[1].split("(", 1)[0].strip()
61
+ return handler in _JAVA_RESERVED_HANDLER_NAMES
62
+
46
63
 
47
64
  # ---------------------------------------------------------------------------
48
65
  # CanonicalSecurity
@@ -438,6 +455,8 @@ def ir_dict_to_canonical(
438
455
  for r in route_surface:
439
456
  if r.get("scope") == "test_util":
440
457
  continue
458
+ if _route_has_reserved_handler(r):
459
+ continue
441
460
  if _FQN_PATH_RE.search(r.get("path", "") or ""):
442
461
  continue
443
462
  ep = _route_to_canonical_endpoint(r)
sourcecode/cli.py CHANGED
@@ -338,14 +338,16 @@ def _build_help_text() -> str:
338
338
  Deterministic Java/Spring semantics and reusable structural context for AI coding agents.
339
339
 
340
340
  Cache warms on first scan; later calls reuse pre-built context instead of rescanning.
341
- Scan and warm time scale with repo size — small repos in seconds, large repos (thousands
342
- of files) in minutes. Semantic analysis itself is sub-second; repo indexing dominates.
341
+ Performance depends on command class and repo size: per-symbol queries are the fast path,
342
+ inventory commands scale with files, and repo-wide/deep compositions can take
343
+ minutes on multi-thousand-endpoint repositories. Use deep jobs nightly or with
344
+ ASK_MAX_ANALYSIS_SECONDS/ASK_PROGRESS when CI needs an explicit budget.
343
345
 
344
346
  [bold]Start here — Java/Spring analysis:[/bold]
345
347
  posture . --diff dev:prod [dim]# effective access, two profile sets (exp.)[/dim]
346
348
  endpoints . [dim]# endpoints + effective path + policy[/dim]
347
349
  spring-audit . [dim]# TX anomalies + security surface[/dim]
348
- migrate-check . --compact [dim]# Boot 2→3: located blockers + effort[/dim]
350
+ migrate-check . --compact [dim]# Boot 2→3 + Java LTS/licensing inventory[/dim]
349
351
  risk . [dim]# defects ranked by reach × access (exp.)[/dim]
350
352
 
351
353
  {_remedy_help_block()}
@@ -881,9 +883,24 @@ def _enforce_format(command: str, fmt: str) -> None:
881
883
 
882
884
 
883
885
  def _safe_write_file(path: "Path", content: str) -> None:
884
- """Write content to path, emitting a clean JSON error on I/O failure."""
886
+ """Atomically write content to path, emitting a clean JSON error on I/O failure."""
885
887
  try:
886
- path.write_text(content, encoding="utf-8")
888
+ path = Path(path)
889
+ path.parent.mkdir(parents=True, exist_ok=True)
890
+ tmp = path.with_name(f".{path.name}.tmp-{os.getpid()}-{time.time_ns()}")
891
+ tmp.write_text(content, encoding="utf-8")
892
+ tmp.replace(path)
893
+ try:
894
+ from sourcecode.runs import active_run
895
+ run = active_run()
896
+ if run is not None:
897
+ run.write(
898
+ status="running",
899
+ output_path=path,
900
+ output_size=path.stat().st_size,
901
+ )
902
+ except Exception:
903
+ pass
887
904
  except OSError as _exc:
888
905
  _emit_error_json(
889
906
  INVALID_INPUT_CODE,
@@ -968,6 +985,50 @@ def _active_command_context() -> "tuple[str, Optional[Path]]":
968
985
 
969
986
 
970
987
  _EXPENSIVE_ANALYSIS_TTL_SECONDS = 6 * 60 * 60
988
+ _ANALYSIS_CLASSES: dict[str, tuple[str, int, str]] = {
989
+ "spring-audit": ("repo-wide", 600, "use nightly/deep tier on large repositories"),
990
+ "risk": ("deep", 1200, "run `ask spring-audit` or a saved `risk.json` first"),
991
+ "audit-report": ("deep", 1200, "reuse `--from-risk risk.json` or schedule nightly"),
992
+ "verify --init": ("repo-wide", 600, "derive contracts outside pre-commit"),
993
+ "verify": ("core", 60, "run with a larger CI budget or narrow the contract set"),
994
+ "migrate-check": ("repo-wide", 600, "run inventory in CI and deep migration checks nightly"),
995
+ }
996
+
997
+
998
+ def _analysis_budget(command: str) -> dict[str, Any]:
999
+ cls, recommended, fallback = _ANALYSIS_CLASSES.get(
1000
+ command, ("repo-wide", 600, "run this command in a supervised or nightly job")
1001
+ )
1002
+ configured: Optional[float] = None
1003
+ raw = os.environ.get("ASK_MAX_ANALYSIS_SECONDS")
1004
+ if raw:
1005
+ try:
1006
+ configured = float(raw)
1007
+ except ValueError:
1008
+ configured = None
1009
+ return {
1010
+ "class": cls,
1011
+ "recommended_min_seconds": recommended,
1012
+ "configured_max_seconds": configured,
1013
+ "fallback": fallback,
1014
+ }
1015
+
1016
+
1017
+ def _enforce_analysis_budget(command: str, budget: dict[str, Any]) -> None:
1018
+ configured = budget.get("configured_max_seconds")
1019
+ recommended = float(budget.get("recommended_min_seconds") or 0)
1020
+ if configured is None or float(configured) >= recommended:
1021
+ return
1022
+ _emit_error_json(
1023
+ INVALID_INPUT_CODE,
1024
+ (
1025
+ f"{command} is a {budget.get('class')} analysis and the configured "
1026
+ f"budget ({configured:g}s) is below its safe floor ({recommended:g}s)."
1027
+ ),
1028
+ hint=str(budget.get("fallback") or "raise ASK_MAX_ANALYSIS_SECONDS"),
1029
+ expected=f"ASK_MAX_ANALYSIS_SECONDS >= {recommended:g}",
1030
+ )
1031
+ raise typer.Exit(code=2)
971
1032
 
972
1033
 
973
1034
  def _analysis_lock_path(path: Path) -> Path:
@@ -1051,8 +1112,14 @@ def _emit_analysis_contention_warning(command: str, path: Path, phase: str) -> N
1051
1112
  @contextlib.contextmanager
1052
1113
  def _expensive_analysis_scope(command: str, path: Path, phase: str):
1053
1114
  """Register a long repository-wide analysis and warn on concurrent runs."""
1115
+ from sourcecode.runs import RunRecord, reset_active_run, set_active_run
1116
+
1054
1117
  lock_path = _analysis_lock_path(path)
1055
1118
  _emit_analysis_contention_warning(command, path, phase)
1119
+ budget = _analysis_budget(command)
1120
+ _enforce_analysis_budget(command, budget)
1121
+ run_record = RunRecord.start(path, command, phase, budget=budget)
1122
+ run_token = set_active_run(run_record)
1056
1123
  token = f"{os.getpid()}:{time.time_ns()}"
1057
1124
  owns_lock = False
1058
1125
  if _active_analysis_record(lock_path) is None:
@@ -1077,6 +1144,12 @@ def _expensive_analysis_scope(command: str, path: Path, phase: str):
1077
1144
  owns_lock = False
1078
1145
  try:
1079
1146
  yield
1147
+ if run_record is not None:
1148
+ run_record.write(status="complete", exit_code=0)
1149
+ except BaseException as exc:
1150
+ if run_record is not None:
1151
+ run_record.write(status="failed", exit_code=1, error=str(exc))
1152
+ raise
1080
1153
  finally:
1081
1154
  if owns_lock:
1082
1155
  try:
@@ -1088,6 +1161,7 @@ def _expensive_analysis_scope(command: str, path: Path, phase: str):
1088
1161
  lock_path.unlink()
1089
1162
  except OSError:
1090
1163
  pass
1164
+ reset_active_run(run_token)
1091
1165
 
1092
1166
 
1093
1167
  def _emit_command_output(
@@ -3875,6 +3949,11 @@ def prepare_context_cmd(
3875
3949
  ask prepare-context review-pr . --since main --output review.json
3876
3950
  ask prepare-context onboard --llm-prompt
3877
3951
  ask prepare-context --task-help
3952
+
3953
+ \b
3954
+ Provenance:
3955
+ JSON output includes evidence_manifest: claim IDs for context files and
3956
+ source-backed rows, plus command inputs, ASK version, git HEAD and freshness.
3878
3957
  """
3879
3958
  from sourcecode.prepare_context import TASKS, TaskContextBuilder
3880
3959
 
@@ -4419,6 +4498,20 @@ def prepare_context_cmd(
4419
4498
  _skipped.append("test_gap_discovery")
4420
4499
  out["skipped_analyzers"] = _skipped
4421
4500
 
4501
+ from sourcecode.provenance import build_evidence_manifest as _build_evidence_manifest
4502
+ out["evidence_manifest"] = _build_evidence_manifest(
4503
+ out,
4504
+ command="prepare-context",
4505
+ root=target,
4506
+ inputs={
4507
+ "task": task,
4508
+ "since": since,
4509
+ "format": format or "json",
4510
+ "fast": fast,
4511
+ "llm_prompt": llm_prompt,
4512
+ },
4513
+ )
4514
+
4422
4515
  # P0-1: Apply output budget per task — safety net for large repos.
4423
4516
  _pc_budget = _prepare_context_budget(task)
4424
4517
 
@@ -8007,6 +8100,11 @@ def audit_report_cmd(
8007
8100
  format: str = typer.Option(
8008
8101
  "json", "--format", "-f", help="Output format: json, yaml or markdown."
8009
8102
  ),
8103
+ from_risk: Optional[Path] = typer.Option(
8104
+ None,
8105
+ "--from-risk",
8106
+ help="Reuse an existing `ask risk -o risk.json` payload instead of recomputing risk.",
8107
+ ),
8010
8108
  copy: bool = _copy_option(),
8011
8109
  ) -> None:
8012
8110
  """[EXPERIMENTAL] Human audit bundle over existing ASK evidence, optionally signed.
@@ -8015,7 +8113,8 @@ def audit_report_cmd(
8015
8113
  This packages the same evidence `risk` and `posture` already publish into a
8016
8114
  report a buyer can read: top risks, effective runtime posture, source-linked
8017
8115
  evidence and non-coverage. It does not certify compliance, choose fixes or
8018
- assign ROI.
8116
+ assign ROI. JSON includes evidence_manifest: claim IDs mapped to source spans,
8117
+ command inputs, ASK version and freshness.
8019
8118
 
8020
8119
  \b
8021
8120
  `--sign-key` adds an HMAC-SHA256 signature over the canonical JSON payload.
@@ -8024,6 +8123,7 @@ def audit_report_cmd(
8024
8123
  \b
8025
8124
  Examples:
8026
8125
  ask audit-report .
8126
+ ask risk . -o risk.json && ask audit-report . --from-risk risk.json
8027
8127
  ask audit-report . --profile prod --format markdown
8028
8128
  ask audit-report . --sign-key audit.key -o audit-report.json
8029
8129
  """
@@ -8059,12 +8159,42 @@ def audit_report_cmd(
8059
8159
  expected="a non-empty key file",
8060
8160
  )
8061
8161
  raise typer.Exit(code=1)
8162
+ risk_payload = None
8163
+ if from_risk is not None:
8164
+ risk_path = Path(from_risk).expanduser()
8165
+ if not risk_path.is_file():
8166
+ _emit_error_json(
8167
+ INVALID_INPUT_CODE,
8168
+ f"--from-risk must name a readable risk JSON file (got {str(from_risk)!r}).",
8169
+ hint="Example: ask risk . -o risk.json && ask audit-report . --from-risk risk.json",
8170
+ expected="an existing JSON file produced by `ask risk`",
8171
+ )
8172
+ raise typer.Exit(code=1)
8173
+ try:
8174
+ risk_payload = json.loads(risk_path.read_text(encoding="utf-8"))
8175
+ except Exception as exc:
8176
+ _emit_error_json(
8177
+ INVALID_INPUT_CODE,
8178
+ f"--from-risk is not valid JSON: {exc}",
8179
+ hint="Regenerate it with: ask risk . -o risk.json",
8180
+ expected="a JSON object produced by `ask risk`",
8181
+ )
8182
+ raise typer.Exit(code=1)
8183
+ if not isinstance(risk_payload, dict) or "risks" not in risk_payload:
8184
+ _emit_error_json(
8185
+ INVALID_INPUT_CODE,
8186
+ "--from-risk does not look like an `ask risk` payload.",
8187
+ hint="Regenerate it with: ask risk . -o risk.json",
8188
+ expected="a JSON object with a `risks` array",
8189
+ )
8190
+ raise typer.Exit(code=1)
8062
8191
 
8063
8192
  _prog = Progress()
8064
8193
  _prog.start("packaging audit evidence")
8065
8194
  try:
8066
8195
  data = build_audit_report(
8067
8196
  path, profiles=_profile_set(profile), sign_key=key_bytes,
8197
+ risk_payload=risk_payload,
8068
8198
  )
8069
8199
  finally:
8070
8200
  _prog.stop()
@@ -8724,9 +8854,9 @@ def migrate_check_cmd(
8724
8854
  None,
8725
8855
  "--target-jdk",
8726
8856
  help=(
8727
- "The JDK you are actually moving to (e.g. 11, 17, 21). Rules for a "
8728
- "later JDK are not your blockers and are not reported. Without it, "
8729
- "every JDK axis is reported at once."
8857
+ "The JDK you are actually moving to (e.g. 11, 17, 21, 25). Rules "
8858
+ "for a later JDK are not your blockers and are not reported. Also "
8859
+ "sets java_lts_inventory.target_lts."
8730
8860
  ),
8731
8861
  ),
8732
8862
  keep_boot: bool = typer.Option(
@@ -8786,7 +8916,7 @@ def migrate_check_cmd(
8786
8916
  help="Caller BFS depth for --blast-radius (1-8, default: 4).",
8787
8917
  ),
8788
8918
  ) -> None:
8789
- """Spring Boot 2→3 migration readiness: detect javax→jakarta namespace blockers.
8919
+ """Spring Boot 2→3 migration readiness + Java LTS/licensing inventory.
8790
8920
 
8791
8921
  \b
8792
8922
  Detects:
@@ -8815,6 +8945,7 @@ def migrate_check_cmd(
8815
8945
  ask migrate-check . --compact bounded decision summary
8816
8946
  ask migrate-check /path/to/repo --format text
8817
8947
  ask migrate-check . --min-severity high
8948
+ ask migrate-check . --target-jdk 25 Java LTS target inventory
8818
8949
  ask migrate-check . --table --rule MIG-001 --band critical --top-n 20
8819
8950
  ask migrate-check . --output migration.json
8820
8951
  ask migrate-check . --snapshot --ref sprint-12 persist a readiness point
@@ -8837,6 +8968,13 @@ def migrate_check_cmd(
8837
8968
  <repo>/.ask/readiness-history). --trend reads that series and reports
8838
8969
  first→last movement — no improving/degrading label, and readiness_score is
8839
8970
  flagged not-comparable when the applicable dimension set changed.
8971
+
8972
+ \b
8973
+ Java LTS/licensing inventory:
8974
+ The JSON report includes java_lts_inventory: Java 8/11/17/21/25 evidence
8975
+ from Maven/Gradle, local runtime hints and container files; target-LTS
8976
+ blockers; and explicit Oracle JDK/licensing-review signals. This is
8977
+ inventory evidence, not legal advice, and it never changes readiness_score.
8840
8978
  """
8841
8979
  from sourcecode.repository_ir import find_java_files
8842
8980
  from sourcecode.migrate_check import run_migrate_check