sourcecode 2.6.5__py3-none-any.whl → 2.7.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.

Files changed (37) hide show
  1. sourcecode/__init__.py +1 -1
  2. sourcecode/archetype.py +11 -0
  3. sourcecode/caller_metrics.py +19 -0
  4. sourcecode/cli.py +281 -63
  5. sourcecode/deployment_prefix.py +229 -0
  6. sourcecode/explain.py +32 -0
  7. sourcecode/file_classifier.py +6 -2
  8. sourcecode/hibernate_strat.py +11 -4
  9. sourcecode/mcp/onboarding/applier.py +29 -15
  10. sourcecode/mcp/onboarding/detector.py +38 -0
  11. sourcecode/mcp/onboarding/planner.py +2 -2
  12. sourcecode/mcp/server.py +45 -6
  13. sourcecode/mcp_nudge.py +3 -2
  14. sourcecode/migrate_check.py +13 -0
  15. sourcecode/pr_impact.py +135 -16
  16. sourcecode/readiness_timeline.py +178 -0
  17. sourcecode/repository_ir.py +285 -13
  18. sourcecode/retrieval/steps_endpoint.py +32 -8
  19. sourcecode/retrieval/steps_graph.py +9 -1
  20. sourcecode/retrieval/steps_intf.py +32 -6
  21. sourcecode/retrieval/steps_txsec.py +114 -6
  22. sourcecode/security_posture.py +41 -8
  23. sourcecode/serializer.py +52 -6
  24. sourcecode/spring_findings.py +44 -0
  25. sourcecode/spring_profiles.py +208 -0
  26. sourcecode/telemetry/__init__.py +2 -0
  27. sourcecode/telemetry/consent.py +2 -1
  28. sourcecode/telemetry/events.py +3 -0
  29. sourcecode/telemetry/filters.py +20 -1
  30. sourcecode/token_estimate.py +213 -0
  31. sourcecode/validation_inference.py +58 -6
  32. sourcecode/validation_surface.py +91 -0
  33. {sourcecode-2.6.5.dist-info → sourcecode-2.7.0.dist-info}/METADATA +1 -1
  34. {sourcecode-2.6.5.dist-info → sourcecode-2.7.0.dist-info}/RECORD +37 -33
  35. {sourcecode-2.6.5.dist-info → sourcecode-2.7.0.dist-info}/WHEEL +0 -0
  36. {sourcecode-2.6.5.dist-info → sourcecode-2.7.0.dist-info}/entry_points.txt +0 -0
  37. {sourcecode-2.6.5.dist-info → sourcecode-2.7.0.dist-info}/licenses/LICENSE +0 -0
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__ = "2.6.5"
7
+ __version__ = "2.7.0"
sourcecode/archetype.py CHANGED
@@ -604,12 +604,23 @@ class ArchetypeClassifier:
604
604
  # Gini alone labeling a REST API an "engine") — is a single point of view and
605
605
  # must not read as high confidence (field-eval Fase 0).
606
606
  distinct_signals = len({e.signal for e in top.evidence if e.contribution > 0})
607
+ # Diversity by COUNT is satisfiable while one signal carries the
608
+ # verdict: an audited repo was labelled "engine" at high confidence on
609
+ # 1.500 of a 1.504 score — a fan-in Gini alone, with the second signal
610
+ # contributing 0.3%. Counting it as two views is a fiction, so a label
611
+ # whose top signal supplies almost all of its score cannot be "high"
612
+ # however many other signals nominally touch it.
613
+ _contributions = sorted(
614
+ (e.contribution for e in top.evidence if e.contribution > 0), reverse=True
615
+ )
616
+ top_signal_share = (_contributions[0] / top.score) if _contributions else 1.0
607
617
  if (
608
618
  confident_enough
609
619
  and rel_margin >= 0.4
610
620
  and mass_backed
611
621
  and graph_backed
612
622
  and distinct_signals >= 2
623
+ and top_signal_share <= 0.8
613
624
  ):
614
625
  confidence = "high"
615
626
  elif confident_enough and rel_margin >= 0.25 and mass_backed:
@@ -40,3 +40,22 @@ CALLER_METRIC_RECONCILIATION: str = (
40
40
  "references or DI-interface resolution). The top-level impact.direct_callers array "
41
41
  "may be truncated for output size — use stats.direct_caller_count for the true total."
42
42
  )
43
+
44
+
45
+ # Cycle / tangle metrics come from TWO different graphs. Both are correct; they
46
+ # answer different questions, and without saying so the pair reads as one command
47
+ # contradicting another (external audit 2026-07-25: modernize reported 15 cyclic
48
+ # tangles while retrieve subsystem-coupling reported import_cycles: 0).
49
+ CYCLE_METRIC_RECONCILIATION: str = (
50
+ "Cycle metrics differ across commands BY DESIGN. modernize.cross_module_tangles "
51
+ "is computed over the SYMBOL dependency graph, aggregated to subsystem packages: "
52
+ "coupling_type=cyclic means two subsystems reference each other (a 2-cycle "
53
+ "between package groups). The module-graph metrics (import_cycles, cyclic_density, "
54
+ "scc_mass — used by archetype and the subsystem-coupling retrieval intent) are "
55
+ "computed over the MODULE/DIRECTORY graph, are bounded for cost (node/edge caps), "
56
+ "and count strongly-connected components among modules. A repo can therefore "
57
+ "show mutual package-level coupling (tangles > 0) with no module-level SCC "
58
+ "(import_cycles = 0): the coupling exists between package groups inside the same "
59
+ "module. Neither number invalidates the other — read tangles for decomposition "
60
+ "risk and import_cycles for module-graph topology."
61
+ )
sourcecode/cli.py CHANGED
@@ -17,7 +17,10 @@ from sourcecode.error_schema import INVALID_INPUT_CODE, build_error_envelope
17
17
  from sourcecode.entrypoint_classifier import is_production_entry_point, normalize_entry_point
18
18
  from sourcecode.progress import Progress
19
19
  from sourcecode import perf
20
- from sourcecode.caller_metrics import CALLER_METRIC_RECONCILIATION
20
+ from sourcecode.caller_metrics import (
21
+ CALLER_METRIC_RECONCILIATION,
22
+ CYCLE_METRIC_RECONCILIATION,
23
+ )
21
24
  from sourcecode.repository_ir import extract_java_endpoints as _extract_java_endpoints
22
25
 
23
26
 
@@ -1851,6 +1854,10 @@ def main(
1851
1854
  )
1852
1855
  except Exception:
1853
1856
  pass # stale value better than crash
1857
+ # C1: token economy on the agent-facing views (cache-hit path).
1858
+ if format == "json" and (compact or agent):
1859
+ from sourcecode.token_estimate import inject_token_economy as _inj_te
1860
+ _cache_hit_content = _inj_te(_cache_hit_content, target)
1854
1861
  _emit_command_output(_cache_hit_content, output, copy)
1855
1862
  return
1856
1863
 
@@ -2699,7 +2706,14 @@ def main(
2699
2706
  }, indent=2, ensure_ascii=False)
2700
2707
  except Exception:
2701
2708
  pass
2702
- _emit_command_output(content, output, copy if not _pipeline_error else False)
2709
+ # C1: token economy on the agent-facing views (fresh path). Emit-only —
2710
+ # `content` stays clean so the cached L2 view never embeds a stale block
2711
+ # (the cache-hit path recomputes it against the working tree).
2712
+ _emit_content = content
2713
+ if format == "json" and (compact or agent):
2714
+ from sourcecode.token_estimate import inject_token_economy as _inj_te
2715
+ _emit_content = _inj_te(content, target)
2716
+ _emit_command_output(_emit_content, output, copy if not _pipeline_error else False)
2703
2717
 
2704
2718
  # Persist to two-layer cache (git SHA unchanged → re-use on next run).
2705
2719
  #
@@ -4461,6 +4475,7 @@ def _build_c4_export(
4461
4475
  endpoints: "list[dict]",
4462
4476
  integrations: "dict",
4463
4477
  endpoint_meta: "Optional[dict]" = None,
4478
+ include_code_level: bool = False,
4464
4479
  ) -> "dict":
4465
4480
  """Assemble a unified, tool-agnostic C4 architecture export + incremental manifest.
4466
4481
 
@@ -4503,7 +4518,18 @@ def _build_c4_export(
4503
4518
  },
4504
4519
  "containers": containers,
4505
4520
  "components": module_graph,
4506
- "code": by_directory,
4521
+ # C4 level 4 (code) is a per-directory enumeration of every symbol —
4522
+ # 18 MB on a large repo, and by C4's own guidance a level you generate
4523
+ # for a specific area rather than wholesale. Opt in with --c4-code.
4524
+ "code": by_directory if include_code_level else {
4525
+ "included": False,
4526
+ "reason": (
4527
+ "C4 level 4 (code) omitted by default: it enumerates every "
4528
+ "symbol per directory and dominates the export size. Pass "
4529
+ "--c4-code to include it."
4530
+ ),
4531
+ "directory_count": len(by_directory),
4532
+ },
4507
4533
  },
4508
4534
  "api_surface": api_surface,
4509
4535
  "manifest": {
@@ -4550,9 +4576,14 @@ def export_cmd(
4550
4576
  ),
4551
4577
  c4: bool = typer.Option(
4552
4578
  False, "--c4",
4553
- help="Unified C4 architecture export (context/containers/components/code) "
4579
+ help="Unified C4 architecture export (context/containers/components) "
4554
4580
  "+ per-directory incremental manifest. Vendor-neutral.",
4555
4581
  ),
4582
+ c4_code: bool = typer.Option(
4583
+ False, "--c4-code",
4584
+ help="Include C4 level 4 (code): every symbol per directory. Omitted by "
4585
+ "default because it dominates the export size (18 MB on a large repo).",
4586
+ ),
4556
4587
  ) -> None:
4557
4588
  """Export structured, tool-agnostic codebase views for downstream tooling.
4558
4589
 
@@ -4630,6 +4661,7 @@ def export_cmd(
4630
4661
  endpoints,
4631
4662
  _integrations,
4632
4663
  endpoint_meta=_ep_data,
4664
+ include_code_level=c4_code,
4633
4665
  )
4634
4666
  _prog.finish()
4635
4667
  output = _serialize_dict(data, format)
@@ -4756,7 +4788,7 @@ def validation_cmd(
4756
4788
  try:
4757
4789
  from sourcecode.validation_inference import infer_validation_pattern
4758
4790
 
4759
- _vres = infer_validation_pattern(_graph.cir)
4791
+ _vres = infer_validation_pattern(_graph.cir, target)
4760
4792
  data["validation_pattern"] = _vres.to_dict()
4761
4793
  # Proposal 1 (Explainability): a compact confidence block projecting the
4762
4794
  # verdict's own confidence tier + rollup + declared precision limits, plus
@@ -5691,24 +5723,7 @@ def spring_audit_cmd(
5691
5723
  if scope in ("all", "security"):
5692
5724
  results.append(run_security_audit(cir, root=target, min_severity=min_severity, model=_model))
5693
5725
 
5694
- if len(results) == 1:
5695
- combined = results[0]
5696
- else:
5697
- all_findings: list[SpringFinding] = []
5698
- all_limitations: list[str] = []
5699
- merged_meta: dict = {}
5700
- for r in results:
5701
- all_findings.extend(r.findings)
5702
- all_limitations.extend(r.limitations)
5703
- merged_meta.update(r.metadata)
5704
- combined = SpringAuditResult(
5705
- repo_id=results[0].repo_id,
5706
- spring_detected=any(r.spring_detected for r in results),
5707
- scope="all",
5708
- findings=all_findings,
5709
- limitations=all_limitations,
5710
- metadata=merged_meta,
5711
- ).finalize()
5726
+ combined = SpringAuditResult.merge(results, scope=scope)
5712
5727
 
5713
5728
  if _file_limitations:
5714
5729
  combined.limitations.extend(_file_limitations)
@@ -5873,6 +5888,23 @@ def migrate_check_cmd(
5873
5888
  False, "--no-cache",
5874
5889
  help="Accepted for compatibility; this command always reads fresh source (no snapshot cache). No-op.",
5875
5890
  ),
5891
+ snapshot: bool = typer.Option(
5892
+ False, "--snapshot",
5893
+ help="Persist this readiness result as a timeline snapshot under --history-dir.",
5894
+ ),
5895
+ trend: bool = typer.Option(
5896
+ False, "--trend",
5897
+ help="Report the readiness trend across stored snapshots (days-remaining over time) "
5898
+ "instead of scanning. Reads --history-dir.",
5899
+ ),
5900
+ history_dir: Optional[Path] = typer.Option(
5901
+ None, "--history-dir",
5902
+ help="Directory of readiness snapshots (default: <repo>/.ask/readiness-history).",
5903
+ ),
5904
+ ref: Optional[str] = typer.Option(
5905
+ None, "--ref",
5906
+ help="Label for a --snapshot capture (e.g. a version or sprint tag).",
5907
+ ),
5876
5908
  ) -> None:
5877
5909
  """Spring Boot 2→3 migration readiness: detect javax→jakarta namespace blockers.
5878
5910
 
@@ -5904,6 +5936,16 @@ def migrate_check_cmd(
5904
5936
  ask migrate-check /path/to/repo --format text
5905
5937
  ask migrate-check . --min-severity high
5906
5938
  ask migrate-check . --output migration.json
5939
+ ask migrate-check . --snapshot --ref sprint-12 persist a readiness point
5940
+ ask migrate-check . --trend days-remaining over time
5941
+
5942
+ \b
5943
+ Readiness time series:
5944
+ --snapshot persists this run's budgetable figures (readiness_score, effort
5945
+ days, per-dimension scores, blocking_count) under --history-dir (default
5946
+ <repo>/.ask/readiness-history). --trend reads that series and reports
5947
+ first→last movement — no improving/degrading label, and readiness_score is
5948
+ flagged not-comparable when the applicable dimension set changed.
5907
5949
  """
5908
5950
  from sourcecode.repository_ir import find_java_files
5909
5951
  from sourcecode.migrate_check import run_migrate_check
@@ -5930,6 +5972,29 @@ def migrate_check_cmd(
5930
5972
  )
5931
5973
  raise typer.Exit(code=1)
5932
5974
 
5975
+ _history_dir = history_dir.resolve() if history_dir else (target / ".ask" / "readiness-history")
5976
+
5977
+ # --trend reads the stored series and reports movement over time; it does not scan.
5978
+ if trend:
5979
+ from sourcecode.readiness_timeline import build_readiness_trend, load_snapshots_dir
5980
+
5981
+ _snaps = load_snapshots_dir(_history_dir) if _history_dir.exists() else []
5982
+ if not _snaps:
5983
+ _emit_error_json(
5984
+ INVALID_INPUT_CODE,
5985
+ f"No readiness snapshots found in '{_history_dir}'.",
5986
+ path=str(_history_dir),
5987
+ hint="Capture points first: ask migrate-check <repo> --snapshot",
5988
+ expected="A directory holding readiness-snapshot-v1 artifacts.",
5989
+ )
5990
+ raise typer.Exit(code=1)
5991
+ _trend = build_readiness_trend(_snaps)
5992
+ _emit_command_output(
5993
+ _serialize_dict(_trend, "json"), output_path, copy,
5994
+ success_msg=f"Readiness trend over {_trend['count']} snapshot(s) → {_history_dir}",
5995
+ )
5996
+ return
5997
+
5933
5998
  _file_limitations: list[str] = []
5934
5999
  file_list = find_java_files(target, limitations=_file_limitations)
5935
6000
  _prog = Progress()
@@ -5950,6 +6015,14 @@ def migrate_check_cmd(
5950
6015
  payload = report.to_compact_dict() if compact else report.to_dict()
5951
6016
  output = _serialize_dict(payload, "json")
5952
6017
 
6018
+ _snapshot_note = ""
6019
+ if snapshot:
6020
+ from sourcecode.readiness_timeline import build_snapshot, write_snapshot
6021
+
6022
+ _snap = build_snapshot(report.to_dict(), ref=ref)
6023
+ _snap_path = write_snapshot(_snap, _history_dir)
6024
+ _snapshot_note = f"; snapshot → {_snap_path}"
6025
+
5953
6026
  _total = report.summary.get("total_findings", 0)
5954
6027
  _emit_command_output(
5955
6028
  output, output_path, copy,
@@ -5957,6 +6030,7 @@ def migrate_check_cmd(
5957
6030
  f"Migration check written to {output_path} "
5958
6031
  f"(score: {report.readiness_score if report.readiness_score is not None else 'N/A'}"
5959
6032
  f"{'/100' if report.readiness_score is not None else ''}, {_total} findings)"
6033
+ f"{_snapshot_note}"
5960
6034
  ),
5961
6035
  )
5962
6036
 
@@ -5964,6 +6038,40 @@ def migrate_check_cmd(
5964
6038
  # ── Spring Impact Chain ───────────────────────────────────────────────────────
5965
6039
 
5966
6040
 
6041
+ _IMPACT_CAPPED_LISTS = (
6042
+ "direct_callers", "indirect_callers", "endpoints_affected",
6043
+ "security_surfaces", "transactional_boundaries", "impact_findings",
6044
+ )
6045
+
6046
+
6047
+ def _cap_impact_lists(data: dict, limit: int) -> dict:
6048
+ """Cap impact-chain leaf arrays, keeping every total exact.
6049
+
6050
+ One hub class produced 324 KB of JSON (511 callers, 262 endpoints, 262
6051
+ security surfaces) from a tool sold on context economy. The COUNTS are the
6052
+ decision signal and stay exact — in `metadata` (callers_total,
6053
+ endpoints_total) and in `truncated_lists` per capped list — while the
6054
+ enumerations, which are what blows the budget, are capped.
6055
+ """
6056
+ if limit <= 0:
6057
+ return data
6058
+ truncated: dict[str, dict] = {}
6059
+ for key in _IMPACT_CAPPED_LISTS:
6060
+ values = data.get(key)
6061
+ if isinstance(values, list) and len(values) > limit:
6062
+ truncated[key] = {"total": len(values), "shown": limit,
6063
+ "omitted": len(values) - limit}
6064
+ data[key] = values[:limit]
6065
+ if truncated:
6066
+ data["truncated_lists"] = truncated
6067
+ data.setdefault("analysis_warnings", []).append(
6068
+ f"Output capped to {limit} items per list; every total stays exact "
6069
+ "(truncated_lists per list, metadata.callers_total / "
6070
+ "metadata.endpoints_total). Use --limit 0 for the full enumeration."
6071
+ )
6072
+ return data
6073
+
6074
+
5967
6075
  @app.command("impact-chain")
5968
6076
  def impact_chain_cmd(
5969
6077
  symbol: str = typer.Argument(
@@ -6006,6 +6114,12 @@ def impact_chain_cmd(
6006
6114
  False, "--no-cache",
6007
6115
  help="Accepted for compatibility; this command always reads fresh source (no snapshot cache). No-op.",
6008
6116
  ),
6117
+ limit: int = typer.Option(
6118
+ 100, "--limit",
6119
+ help="Maximum items per list section (callers, endpoints, security "
6120
+ "surfaces). Totals stay exact in metadata/truncated_lists. "
6121
+ "0 disables the cap.",
6122
+ ),
6009
6123
  ) -> None:
6010
6124
  """Spring impact-chain: systemic blast radius of a symbol with TX/SEC enrichment.
6011
6125
 
@@ -6128,6 +6242,7 @@ def impact_chain_cmd(
6128
6242
  result = run_impact_chain(cir, symbol, depth=depth, root=target, model=_model)
6129
6243
 
6130
6244
  data = result.to_dict()
6245
+ data = _cap_impact_lists(data, limit)
6131
6246
  _prog.finish()
6132
6247
  output = _serialize_dict(data, format)
6133
6248
  _emit_command_output(
@@ -6152,10 +6267,14 @@ def pr_impact_cmd(
6152
6267
  Path("."),
6153
6268
  help="Repository root (default: current directory)",
6154
6269
  ),
6155
- files: Path = typer.Option(
6270
+ files: str = typer.Option(
6156
6271
  ...,
6157
6272
  "--files",
6158
- help="File containing the list of changed Java files, one path per line.",
6273
+ help=(
6274
+ "Changed files to analyze: a text file with one path per line "
6275
+ "(e.g. from `git diff --name-only`), a comma-separated list of paths, "
6276
+ "or '-' to read the list from stdin."
6277
+ ),
6159
6278
  ),
6160
6279
  output_path: Optional[Path] = typer.Option(
6161
6280
  None, "--output", "-o",
@@ -6166,6 +6285,14 @@ def pr_impact_cmd(
6166
6285
  help="Output format: text (default) or json.",
6167
6286
  show_default=True,
6168
6287
  ),
6288
+ fail_on: Optional[str] = typer.Option(
6289
+ None, "--fail-on",
6290
+ help=(
6291
+ "Exit non-zero when the risk level reaches this threshold: "
6292
+ "critical | high | medium | low | unknown. UNKNOWN (partial evidence) "
6293
+ "always fails when a threshold is set."
6294
+ ),
6295
+ ),
6169
6296
  copy: bool = typer.Option(
6170
6297
  False, "--copy", "-c",
6171
6298
  help="Copy output to clipboard after a successful run.",
@@ -6181,6 +6308,11 @@ def pr_impact_cmd(
6181
6308
  - Event publishers and consumers triggered by the change
6182
6309
  - @Transactional methods in the changed classes
6183
6310
  - Consolidated risk level (CRITICAL / HIGH / MEDIUM / LOW)
6311
+ - Changed files that could NOT be analyzed (unresolved / non-Java)
6312
+
6313
+ \b
6314
+ A changed Java file that maps to no class caps the verdict at UNKNOWN:
6315
+ a partial blast radius is never reported as a low one.
6184
6316
 
6185
6317
  \b
6186
6318
  Reuses existing graph and impact analysis — no new parsers.
@@ -6190,7 +6322,8 @@ def pr_impact_cmd(
6190
6322
  Examples:
6191
6323
  ask pr-impact --files changed_files.txt
6192
6324
  ask pr-impact /path/to/repo --files diff.txt --format json
6193
- ask pr-impact --files changes.txt --output pr_report.txt
6325
+ ask pr-impact --files A.java,B.java --fail-on high
6326
+ git diff --name-only origin/main | ask pr-impact --files -
6194
6327
  """
6195
6328
  import json as _json
6196
6329
 
@@ -6210,33 +6343,53 @@ def pr_impact_cmd(
6210
6343
  )
6211
6344
  raise typer.Exit(code=1)
6212
6345
 
6213
- if not files.exists() or files.is_dir():
6214
- _emit_error_json(
6215
- INVALID_INPUT_CODE,
6216
- f"--files '{files}' does not exist or is a directory. Expected a text file listing changed file paths (one per line).",
6217
- path=str(files),
6218
- hint=(
6219
- "Create a file with one changed Java file path per line, then pass it with --files. "
6220
- "Example: git diff --name-only HEAD~1 > changed.txt && ask pr-impact . --files changed.txt"
6221
- ),
6222
- expected="A text file containing one Java file path per line.",
6223
- )
6224
- raise typer.Exit(code=1)
6225
-
6226
6346
  _enforce_format("pr-impact", format)
6227
6347
 
6228
- # Read changed-files list
6229
- changed_files = [
6230
- line.strip()
6231
- for line in files.read_text(encoding="utf-8").splitlines()
6232
- if line.strip()
6233
- ]
6348
+ # --files accepts three shapes: a list file, '-' for stdin, or an inline
6349
+ # comma-separated list. The list-file shape was the only documented one and
6350
+ # the only one the error hint mentioned.
6351
+ _files_raw = str(files).strip()
6352
+ if _files_raw == "-":
6353
+ changed_files = [ln.strip() for ln in sys.stdin.read().splitlines() if ln.strip()]
6354
+ _files_desc = "stdin"
6355
+ else:
6356
+ _files_path = Path(_files_raw)
6357
+ if _files_path.is_file():
6358
+ changed_files = [
6359
+ line.strip()
6360
+ for line in _files_path.read_text(encoding="utf-8").splitlines()
6361
+ if line.strip()
6362
+ ]
6363
+ _files_desc = str(_files_path)
6364
+ elif all(
6365
+ p.strip().lower().endswith(".java")
6366
+ for p in _files_raw.split(",") if p.strip()
6367
+ ):
6368
+ # Inline path list. Restricted to .java entries so that a mistyped
6369
+ # list-file path ("changd.txt") still errors loudly instead of being
6370
+ # read as a one-element list of changed files.
6371
+ changed_files = [p.strip() for p in _files_raw.split(",") if p.strip()]
6372
+ _files_desc = "inline list"
6373
+ else:
6374
+ _emit_error_json(
6375
+ INVALID_INPUT_CODE,
6376
+ f"--files '{_files_raw}' is not a readable file, a comma-separated list, or '-'.",
6377
+ path=_files_raw,
6378
+ hint=(
6379
+ "Pass a file with one changed path per line "
6380
+ "(git diff --name-only HEAD~1 > changed.txt && ask pr-impact . --files changed.txt), "
6381
+ "a comma-separated list (--files A.java,B.java), or '-' to read from stdin."
6382
+ ),
6383
+ expected="A list file, a comma-separated list of paths, or '-'.",
6384
+ )
6385
+ raise typer.Exit(code=1)
6386
+
6234
6387
  if not changed_files:
6235
6388
  _emit_error_json(
6236
6389
  INVALID_INPUT_CODE,
6237
- f"--files '{files}' is empty.",
6238
- hint="File must contain at least one Java file path.",
6239
- expected="One Java file path per line.",
6390
+ f"--files '{_files_desc}' is empty.",
6391
+ hint="Provide at least one changed file path.",
6392
+ expected="One file path per line, or a comma-separated list.",
6240
6393
  )
6241
6394
  raise typer.Exit(code=1)
6242
6395
 
@@ -6277,6 +6430,23 @@ def pr_impact_cmd(
6277
6430
  ),
6278
6431
  )
6279
6432
 
6433
+ if fail_on:
6434
+ _levels = {"critical": 4, "high": 3, "medium": 2, "low": 1, "unknown": 0}
6435
+ _threshold = _levels.get(fail_on.strip().lower())
6436
+ if _threshold is None:
6437
+ _emit_error_json(
6438
+ INVALID_INPUT_CODE,
6439
+ f"--fail-on '{fail_on}' is not a risk level.",
6440
+ hint="Use one of: critical, high, medium, low, unknown.",
6441
+ expected="critical | high | medium | low | unknown",
6442
+ )
6443
+ raise typer.Exit(code=1)
6444
+ _actual = _levels.get(report.risk_level.lower(), 0)
6445
+ # UNKNOWN means the analysis could not see everything it was asked about.
6446
+ # A gate must not read that as "below threshold".
6447
+ if report.risk_level == "UNKNOWN" or _actual >= _threshold:
6448
+ raise typer.Exit(code=1)
6449
+
6280
6450
 
6281
6451
  # ── Explain Command ───────────────────────────────────────────────────────────
6282
6452
 
@@ -6301,6 +6471,11 @@ def explain_cmd(
6301
6471
  "human-readable text/Markdown, NOT JSON — pass --format json (or use a "
6302
6472
  ".json output path) for machine-readable output.",
6303
6473
  ),
6474
+ limit: int = typer.Option(
6475
+ 50, "--limit",
6476
+ help="Maximum items per list section (callers, methods, deps). "
6477
+ "0 disables the cap. Truncation is always reported in warnings.",
6478
+ ),
6304
6479
  copy: bool = typer.Option(
6305
6480
  False, "--copy", "-c",
6306
6481
  help="Copy output to clipboard after a successful run.",
@@ -6398,7 +6573,7 @@ def explain_cmd(
6398
6573
  except Exception:
6399
6574
  cir = ContextGraph.build(file_list, target).cir # fallback: never break explain
6400
6575
  model = SpringSemanticModel.build(cir)
6401
- explanation = explain_class(class_name, cir, model)
6576
+ explanation = explain_class(class_name, cir, model).capped(limit)
6402
6577
  finally:
6403
6578
  _prog.finish()
6404
6579
  if _cc_look is not None:
@@ -7096,7 +7271,8 @@ def modernize_cmd(
7096
7271
  "that blocks independent module extraction; coupling_type="
7097
7272
  "directional (reverse_edge_count=0) is a normal one-way layered "
7098
7273
  "dependency, listed for coupling strength, NOT a tangle. Empty "
7099
- "means no cross-subsystem structural coupling was detected."
7274
+ "means no cross-subsystem structural coupling was detected. "
7275
+ + CYCLE_METRIC_RECONCILIATION
7100
7276
  ),
7101
7277
  # BUG-05 fix: don't recommend "Start with hotspot_candidates" when the list is empty.
7102
7278
  "recommendation": (
@@ -7371,13 +7547,33 @@ def auth_status_cmd() -> None:
7371
7547
  """Show current authentication and plan status."""
7372
7548
  import json as _json
7373
7549
  try:
7374
- from sourcecode.license import _license_data as _ld, is_pro as _ip
7550
+ from sourcecode.license import (
7551
+ _PRO_UNLOCK_ALL as _unlock,
7552
+ _license_data as _ld,
7553
+ is_pro as _ip,
7554
+ )
7375
7555
  except Exception:
7376
7556
  _ld = None
7377
7557
  _ip = False
7558
+ _unlock = False
7559
+
7560
+ # While the early-adoption unlock is on, every Pro command runs. Reporting
7561
+ # `pro: false` here contradicted the --help header and the commands' own
7562
+ # behaviour: the status has to describe the gating that is actually in force,
7563
+ # not the credential that happens to be absent.
7564
+ _gating = {
7565
+ "pro_gating": "disabled" if _unlock else "enabled",
7566
+ "pro_effective": bool(_ip or _unlock),
7567
+ }
7568
+ if _unlock:
7569
+ _gating["pro_reason"] = "early-adoption unlock: Pro commands run without a license"
7378
7570
 
7379
7571
  if not _ld:
7380
- out: dict = {"status": "unauthenticated", "pro": False}
7572
+ out: dict = {
7573
+ "status": "unauthenticated",
7574
+ "pro": bool(_unlock),
7575
+ **_gating,
7576
+ }
7381
7577
  sys.stdout.write(_json.dumps(out, ensure_ascii=False) + "\n")
7382
7578
  sys.stdout.flush()
7383
7579
  return
@@ -7390,6 +7586,7 @@ def auth_status_cmd() -> None:
7390
7586
  "plan_status": _ld.get("status", "unknown"),
7391
7587
  "pro": _ip,
7392
7588
  "validated_at": _ld.get("validated_at") or _ld.get("activated_at") or "",
7589
+ **_gating,
7393
7590
  }
7394
7591
  sys.stdout.write(_json.dumps(out, indent=2, ensure_ascii=False) + "\n")
7395
7592
  sys.stdout.flush()
@@ -7965,11 +8162,15 @@ def cold_start_cmd(
7965
8162
  result["endpoints"] = result["endpoints"][:30]
7966
8163
  result["_meta"] = {**(result.get("_meta") or {}), "compact_mode": True,
7967
8164
  "full_available": "ask cold-start (without --compact)"}
8165
+ from sourcecode.token_estimate import estimate_tokens as _est_tokens, token_economy as _tok_econ
7968
8166
  _out = _json.dumps(result, indent=2, ensure_ascii=False)
7969
8167
  _size = len(_out.encode("utf-8"))
7970
- _tokens = _size // 4
8168
+ _tokens = _est_tokens(_out)
7971
8169
  _out_with_meta = _json.loads(_out)
7972
- _out_with_meta.setdefault("_meta", {})["estimated_tokens"] = _tokens
8170
+ _meta_cs = _out_with_meta.setdefault("_meta", {})
8171
+ _meta_cs["estimated_tokens"] = _tokens
8172
+ # C1: what this response costs vs. what reading the same files raw would.
8173
+ _meta_cs["token_economy"] = _tok_econ(_out, _out_with_meta, target)
7973
8174
  _out = _json.dumps(_out_with_meta, indent=2, ensure_ascii=False)
7974
8175
  if not compact and _size > 400_000:
7975
8176
  sys.stderr.write(
@@ -8086,7 +8287,9 @@ def mcp_init(
8086
8287
  typer.echo("No MCP clients found on this system.")
8087
8288
  typer.echo("")
8088
8289
  typer.echo("Manual setup — add to your MCP client config:")
8089
- typer.echo(' "sourcecode": {"command": "sourcecode", "args": ["mcp", "serve"]}')
8290
+ typer.echo(' "ask": {"command": "ask", "args": ["mcp", "serve"]}')
8291
+ typer.echo(' (VS Code keys these under "servers" and wants "type": "stdio";')
8292
+ typer.echo(' other clients use "mcpServers".)')
8090
8293
  raise typer.Exit(code=0)
8091
8294
 
8092
8295
  # Show detection results
@@ -8138,7 +8341,9 @@ def mcp_init(
8138
8341
  if a.client.config_path.exists():
8139
8342
  bak = backup.create(a.client.config_path)
8140
8343
  typer.echo(f" ✓ Backup {bak}")
8141
- updated = applier.apply_entry(config)
8344
+ updated = applier.apply_entry(
8345
+ config, a.client.servers_key, a.client.entry_extra
8346
+ )
8142
8347
  applier.write_config(a.client.config_path, updated)
8143
8348
  if not applier.validate(a.client.config_path):
8144
8349
  errors.append(f"{a.client.name}: JSON validation failed after write")
@@ -8222,16 +8427,18 @@ def mcp_status() -> None:
8222
8427
  typer.echo(f" Fix: ask mcp init --target {client.slug}")
8223
8428
  continue
8224
8429
  config = applier.read_config(client.config_path)
8225
- if applier.is_installed(config):
8430
+ if applier.is_installed(config, client.servers_key):
8226
8431
  typer.echo(f" {client.name:<20} ✓ configured {client.config_path}")
8227
8432
  # FIX-P0-5: inspect registered command for external-server drift.
8228
- _registered = config.get("mcpServers", {}).get("sourcecode", {})
8433
+ # Reads through the client's own servers key and entry name, so drift is
8434
+ # still detected for clients that key their servers differently.
8435
+ _registered = applier.registered_entry(config, client.servers_key)
8229
8436
  _reg_cmd = _registered.get("command", "")
8230
8437
  _reg_args = _registered.get("args", [])
8231
8438
  # Built-in form: command=sourcecode args=[mcp, serve] (or just the binary)
8232
8439
  _is_builtin = (
8233
- _reg_cmd == "sourcecode"
8234
- or (not _reg_args and _reg_cmd.endswith("/sourcecode"))
8440
+ _reg_cmd in ("ask", "sourcecode")
8441
+ or (not _reg_args and _reg_cmd.endswith(("/ask", "/sourcecode")))
8235
8442
  or (_reg_args and _reg_args[:2] == ["mcp", "serve"])
8236
8443
  )
8237
8444
  if _is_builtin:
@@ -8292,7 +8499,7 @@ def mcp_status() -> None:
8292
8499
  for _c in clients:
8293
8500
  if _c.app_installed:
8294
8501
  _cfg = applier.read_config(_c.config_path)
8295
- if applier.is_installed(_cfg):
8502
+ if applier.is_installed(_cfg, _c.servers_key):
8296
8503
  _configured_clients.add(_c.slug)
8297
8504
 
8298
8505
  # Stage 3: Process liveness — is the client app currently running?
@@ -8372,7 +8579,7 @@ def mcp_remove(
8372
8579
  bak = backup.create(a.client.config_path)
8373
8580
  typer.echo(f" ✓ Backup {bak}")
8374
8581
  config = applier.read_config(a.client.config_path)
8375
- updated = applier.remove_entry(config)
8582
+ updated = applier.remove_entry(config, a.client.servers_key)
8376
8583
  applier.write_config(a.client.config_path, updated)
8377
8584
  if not applier.validate(a.client.config_path):
8378
8585
  errors.append(f"{a.client.name}: JSON validation failed — restoring backup")
@@ -8545,8 +8752,19 @@ def cache_warm_cmd(
8545
8752
  """
8546
8753
  import shutil as _shutil
8547
8754
  import subprocess as _sub
8548
- target = _resolve_repo_root(Path(path))
8755
+ # Warm exactly the path given. Resolving up to the enclosing git root warmed
8756
+ # (and reported on) the whole monorepo when asked for one module — every other
8757
+ # command scopes to the argument, so `cache warm ./service-a` populated a cache
8758
+ # for a different target than `ask ./service-a` reads.
8759
+ target = Path(path).resolve()
8760
+ _git_root = _resolve_repo_root(Path(path))
8549
8761
  typer.echo(f"Warming cache for {target} …", err=True)
8762
+ if _git_root != target:
8763
+ typer.echo(
8764
+ f"(scoped to {target}; the enclosing git repository is {_git_root} — "
8765
+ "git-derived signals are computed within the scope above)",
8766
+ err=True,
8767
+ )
8550
8768
  _sc_bin = _shutil.which("sourcecode") or sys.argv[0]
8551
8769
  cmd = [_sc_bin, str(target)]
8552
8770
  if compact: