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.
- sourcecode/__init__.py +1 -1
- sourcecode/archetype.py +11 -0
- sourcecode/caller_metrics.py +19 -0
- sourcecode/cli.py +281 -63
- sourcecode/deployment_prefix.py +229 -0
- sourcecode/explain.py +32 -0
- sourcecode/file_classifier.py +6 -2
- sourcecode/hibernate_strat.py +11 -4
- sourcecode/mcp/onboarding/applier.py +29 -15
- sourcecode/mcp/onboarding/detector.py +38 -0
- sourcecode/mcp/onboarding/planner.py +2 -2
- sourcecode/mcp/server.py +45 -6
- sourcecode/mcp_nudge.py +3 -2
- sourcecode/migrate_check.py +13 -0
- sourcecode/pr_impact.py +135 -16
- sourcecode/readiness_timeline.py +178 -0
- sourcecode/repository_ir.py +285 -13
- sourcecode/retrieval/steps_endpoint.py +32 -8
- sourcecode/retrieval/steps_graph.py +9 -1
- sourcecode/retrieval/steps_intf.py +32 -6
- sourcecode/retrieval/steps_txsec.py +114 -6
- sourcecode/security_posture.py +41 -8
- sourcecode/serializer.py +52 -6
- sourcecode/spring_findings.py +44 -0
- sourcecode/spring_profiles.py +208 -0
- sourcecode/telemetry/__init__.py +2 -0
- sourcecode/telemetry/consent.py +2 -1
- sourcecode/telemetry/events.py +3 -0
- sourcecode/telemetry/filters.py +20 -1
- sourcecode/token_estimate.py +213 -0
- sourcecode/validation_inference.py +58 -6
- sourcecode/validation_surface.py +91 -0
- {sourcecode-2.6.5.dist-info → sourcecode-2.7.0.dist-info}/METADATA +1 -1
- {sourcecode-2.6.5.dist-info → sourcecode-2.7.0.dist-info}/RECORD +37 -33
- {sourcecode-2.6.5.dist-info → sourcecode-2.7.0.dist-info}/WHEEL +0 -0
- {sourcecode-2.6.5.dist-info → sourcecode-2.7.0.dist-info}/entry_points.txt +0 -0
- {sourcecode-2.6.5.dist-info → sourcecode-2.7.0.dist-info}/licenses/LICENSE +0 -0
sourcecode/__init__.py
CHANGED
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:
|
sourcecode/caller_metrics.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
6270
|
+
files: str = typer.Option(
|
|
6156
6271
|
...,
|
|
6157
6272
|
"--files",
|
|
6158
|
-
help=
|
|
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
|
|
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
|
-
#
|
|
6229
|
-
|
|
6230
|
-
|
|
6231
|
-
|
|
6232
|
-
|
|
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 '{
|
|
6238
|
-
hint="
|
|
6239
|
-
expected="One
|
|
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
|
|
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 = {
|
|
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 =
|
|
8168
|
+
_tokens = _est_tokens(_out)
|
|
7971
8169
|
_out_with_meta = _json.loads(_out)
|
|
7972
|
-
_out_with_meta.setdefault("_meta", {})
|
|
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(' "
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|