sourcecode 3.5.0__py3-none-any.whl → 3.6.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.
- sourcecode/__init__.py +1 -1
- sourcecode/cli.py +248 -34
- sourcecode/compare.py +70 -5
- sourcecode/hibernate_strat.py +60 -7
- sourcecode/migrate_check.py +244 -19
- sourcecode/output_budget.py +28 -0
- sourcecode/repository_ir.py +96 -15
- sourcecode/serializer.py +81 -8
- sourcecode/spring_findings.py +38 -0
- sourcecode/spring_security_audit.py +6 -0
- sourcecode/spring_tx_analyzer.py +6 -0
- sourcecode/verify_repo.py +22 -4
- {sourcecode-3.5.0.dist-info → sourcecode-3.6.0.dist-info}/METADATA +1 -1
- {sourcecode-3.5.0.dist-info → sourcecode-3.6.0.dist-info}/RECORD +17 -17
- {sourcecode-3.5.0.dist-info → sourcecode-3.6.0.dist-info}/WHEEL +0 -0
- {sourcecode-3.5.0.dist-info → sourcecode-3.6.0.dist-info}/entry_points.txt +0 -0
- {sourcecode-3.5.0.dist-info → sourcecode-3.6.0.dist-info}/licenses/LICENSE +0 -0
sourcecode/__init__.py
CHANGED
sourcecode/cli.py
CHANGED
|
@@ -857,7 +857,13 @@ def _emit_command_output(
|
|
|
857
857
|
if output_path is not None:
|
|
858
858
|
_safe_write_file(output_path, content)
|
|
859
859
|
if success_msg:
|
|
860
|
-
|
|
860
|
+
# C3-26/C3-18: the payload went to the file, so stdout is free and
|
|
861
|
+
# this confirmation is the run's whole answer — `cache warm` and
|
|
862
|
+
# `cache clear` were corrected for exactly this in 3.5.0 and these
|
|
863
|
+
# three (`repo-ir`, `modernize`, `export`) were missed because the
|
|
864
|
+
# stderr battery listed its commands by hand. Measured before the
|
|
865
|
+
# fix on spring-petclinic: stdout 0 bytes, stderr 155.
|
|
866
|
+
typer.echo(success_msg)
|
|
861
867
|
else:
|
|
862
868
|
try:
|
|
863
869
|
sys.stdout.buffer.write(content.encode("utf-8"))
|
|
@@ -874,30 +880,112 @@ def _emit_command_output(
|
|
|
874
880
|
except AttributeError:
|
|
875
881
|
sys.stdout.write(content + "\n")
|
|
876
882
|
if copy and _copy_to_clipboard(content):
|
|
877
|
-
|
|
883
|
+
# The payload is on stdout here, so this cannot join it; it is a
|
|
884
|
+
# notice and takes the terminal gate rather than dirtying a capture.
|
|
885
|
+
_notice("✓ copied to clipboard")
|
|
878
886
|
|
|
879
887
|
|
|
880
888
|
# H-06: Intercept Click-level UsageError (unknown options, bad args) and emit JSON.
|
|
881
889
|
# Click's default show() writes "Error: No such option: --foo" as plain text.
|
|
882
890
|
# Automation consumers need JSON on stderr regardless of how the error originated.
|
|
891
|
+
def _root_level_flags() -> set[str]:
|
|
892
|
+
"""Every option declared on the root command, discovered from the CLI.
|
|
893
|
+
|
|
894
|
+
Read at error time so a flag added to the root callback is covered without
|
|
895
|
+
anyone remembering to list it here — the C4-1 lesson about curated lists
|
|
896
|
+
living beside generated ones. Never raises: a failure here must not turn a
|
|
897
|
+
usage error into a traceback.
|
|
898
|
+
"""
|
|
899
|
+
try:
|
|
900
|
+
import typer.main as _tm
|
|
901
|
+
|
|
902
|
+
group = _tm.get_command(app)
|
|
903
|
+
return {opt for param in group.params for opt in param.opts}
|
|
904
|
+
except Exception:
|
|
905
|
+
return set()
|
|
906
|
+
|
|
907
|
+
|
|
883
908
|
try:
|
|
884
909
|
import click.exceptions as _click_exc
|
|
885
910
|
|
|
911
|
+
def _misplaced_global_flag_hint(exc: Any) -> "tuple[Optional[str], Optional[str]]":
|
|
912
|
+
"""(flag, hint) when a rejected option is really a root-level one.
|
|
913
|
+
|
|
914
|
+
C3-4: `ask spring-audit --full` answered "No such option: --full" about
|
|
915
|
+
a flag the tool does have — it is declared on the root command, not on
|
|
916
|
+
the subcommand, and nothing said so. Refusing is right; refusing
|
|
917
|
+
without naming where the flag lives is what cost the user the run.
|
|
918
|
+
|
|
919
|
+
The flag is looked up on the root command, so every global flag typed
|
|
920
|
+
after every subcommand is covered and no flag name is written here.
|
|
921
|
+
"""
|
|
922
|
+
flag = str(
|
|
923
|
+
(getattr(exc, "option_name", None) or getattr(exc, "param_hint", None)) or ""
|
|
924
|
+
).strip("'\"")
|
|
925
|
+
if not flag or flag not in _root_level_flags():
|
|
926
|
+
return (flag or None), None
|
|
927
|
+
cmd_path = getattr(getattr(exc, "ctx", None), "command_path", "") or ""
|
|
928
|
+
parts = cmd_path.split()
|
|
929
|
+
sub = parts[-1] if len(parts) > 1 else ""
|
|
930
|
+
hint = (
|
|
931
|
+
f"`{flag}` is a global flag: it goes before the command, "
|
|
932
|
+
f"as `ask {flag} <path>`" + (f", not after `{sub}`." if sub else ".")
|
|
933
|
+
)
|
|
934
|
+
return flag, hint
|
|
935
|
+
|
|
886
936
|
def _json_click_usage_error_show(self: Any, file: Any = None) -> None: # type: ignore[override]
|
|
887
937
|
import json as _je
|
|
888
|
-
_flag
|
|
938
|
+
_flag, _hint = _misplaced_global_flag_hint(self)
|
|
889
939
|
_context: dict[str, object] = {}
|
|
890
940
|
if _flag:
|
|
891
941
|
_context["flag"] = _flag
|
|
942
|
+
if _hint:
|
|
943
|
+
_context["scope"] = "root"
|
|
892
944
|
payload = build_error_envelope(
|
|
893
945
|
INVALID_INPUT_CODE,
|
|
894
946
|
self.format_message(),
|
|
947
|
+
hint=_hint,
|
|
895
948
|
**_context,
|
|
896
949
|
)
|
|
897
950
|
sys.stderr.write(_je.dumps(payload, ensure_ascii=False) + "\n")
|
|
898
951
|
sys.stderr.flush()
|
|
899
952
|
|
|
900
953
|
_click_exc.UsageError.show = _json_click_usage_error_show # type: ignore[method-assign]
|
|
954
|
+
|
|
955
|
+
# ...and again where Typer actually goes. With `rich_markup_mode` set — it
|
|
956
|
+
# is, for the help panels — typer.core routes every ClickException to
|
|
957
|
+
# `rich_utils.rich_format_error` and *never* calls `.show()`, so the patch
|
|
958
|
+
# above was dead for this app and no usage error has been emitting the JSON
|
|
959
|
+
# envelope: `ask <cmd> --badflag` answered with a rich text box on stderr.
|
|
960
|
+
# That is the same broken contract C3-23 closed for `archetype`, reached by
|
|
961
|
+
# a different route. Patched here rather than by dropping rich mode, which
|
|
962
|
+
# would cost the C4-1/C4-2 help panels.
|
|
963
|
+
try:
|
|
964
|
+
import typer.rich_utils as _typer_rich
|
|
965
|
+
|
|
966
|
+
_rich_format_error_original = _typer_rich.rich_format_error
|
|
967
|
+
|
|
968
|
+
def _json_rich_format_error(exc: Any) -> None:
|
|
969
|
+
"""JSON for a pipeline, the usage box for a person.
|
|
970
|
+
|
|
971
|
+
Audience, not preference — the same reasoning as `_notice`. A
|
|
972
|
+
consumer parses stderr and needs the envelope; a human at a
|
|
973
|
+
terminal needs the `Usage:` line and the pointer to `--help`, and
|
|
974
|
+
would get neither from JSON. Nothing is lost either way.
|
|
975
|
+
"""
|
|
976
|
+
if sys.stderr.isatty():
|
|
977
|
+
_rich_format_error_original(exc)
|
|
978
|
+
# The person who mistyped the flag is exactly who needs C3-4's
|
|
979
|
+
# hint, so it follows the box rather than living only in JSON.
|
|
980
|
+
_, _hint = _misplaced_global_flag_hint(exc)
|
|
981
|
+
if _hint:
|
|
982
|
+
typer.echo(_hint, err=True)
|
|
983
|
+
else:
|
|
984
|
+
_json_click_usage_error_show(exc)
|
|
985
|
+
|
|
986
|
+
_typer_rich.rich_format_error = _json_rich_format_error # type: ignore[assignment]
|
|
987
|
+
except Exception:
|
|
988
|
+
pass # rich unavailable — .show() above already covers the plain path
|
|
901
989
|
except Exception:
|
|
902
990
|
pass # click unavailable — plain-text fallback
|
|
903
991
|
|
|
@@ -1081,6 +1169,64 @@ _FREE_TIER_NODE_CAP: int = 50 # graph/semantic node cap — applies only to lar
|
|
|
1081
1169
|
_JAVA_MIN_SCAN_DEPTH: int = 12 # Maven src/main/java/<pkg>/<module>/File depth floor
|
|
1082
1170
|
_LARGE_REPO_ADVISORY_FILES: int = 3000 # cold-scan advisory threshold (files); honest expectation-setting, not a speed claim
|
|
1083
1171
|
|
|
1172
|
+
#: What one Java file costs in serialized `repo-ir` output, measured across four
|
|
1173
|
+
#: repositories (C3-18): spring-petclinic 30 files/318 KB (10.8 KB/file),
|
|
1174
|
+
#: jobrunr 581/9.5 MB (16.8), openmrs-core 866/21 MB (25.2), eureka 299/8.0 MB
|
|
1175
|
+
#: (27.3). Kept as a band, never collapsed to a midpoint: the 2.6x spread is
|
|
1176
|
+
#: real — it tracks how densely the code cross-references — and a point estimate
|
|
1177
|
+
#: would assert a precision the sample does not support (the C1-12 rule).
|
|
1178
|
+
_IR_BYTES_PER_FILE_LOW: int = 10_800
|
|
1179
|
+
_IR_BYTES_PER_FILE_HIGH: int = 27_900
|
|
1180
|
+
_IR_SIZE_WARN_MB: int = 10 # advise above this; below it the write is not worth a sentence
|
|
1181
|
+
|
|
1182
|
+
#: What `--no-cache` means on a subcommand (C3-5). The old text — "this command
|
|
1183
|
+
#: always reads fresh source (no snapshot cache)" — was false: `cache model`, the
|
|
1184
|
+
#: authority, records these commands reading the `ris`, `cir` and `parse` layers.
|
|
1185
|
+
#: They do skip the *snapshot* layer, which is the only thing the sentence got
|
|
1186
|
+
#: right, and it read as a promise about all caching.
|
|
1187
|
+
#:
|
|
1188
|
+
#: The flag is a genuine no-op here, and for a reason worth stating rather than
|
|
1189
|
+
#: asserting: since C1-9 every layer keys on the exact tree state and the
|
|
1190
|
+
#: analyzer fingerprint, and the parse layer is content-addressed, so none of
|
|
1191
|
+
#: them can serve an answer for code that has changed. There is no staleness for
|
|
1192
|
+
#: the flag to bypass. It is accepted so a script can pass it uniformly, and it
|
|
1193
|
+
#: is real on the root command, where the snapshot layer does store a rendered
|
|
1194
|
+
#: answer.
|
|
1195
|
+
_NO_CACHE_SUBCOMMAND_HELP = (
|
|
1196
|
+
"Accepted for compatibility; a no-op here. This command's layers key on the "
|
|
1197
|
+
"tree state, so none can serve a stale answer. Real on `ask --no-cache` "
|
|
1198
|
+
"(root), which caches a rendered answer. See `ask cache model`."
|
|
1199
|
+
)
|
|
1200
|
+
|
|
1201
|
+
|
|
1202
|
+
def _ir_size_advisory_message(file_count: int) -> "Optional[str]":
|
|
1203
|
+
"""Return the `repo-ir` output-size advisory, or ``None`` below the threshold.
|
|
1204
|
+
|
|
1205
|
+
C3-18: the cost is stated *before* it is paid. The old advisory ran after
|
|
1206
|
+
the IR was built and serialized — the whole bill already settled and the
|
|
1207
|
+
file already on disk — so it could tell a user what they had just spent,
|
|
1208
|
+
never what they were about to.
|
|
1209
|
+
|
|
1210
|
+
The figure is a band, not a point. Sampled across four repositories the IR
|
|
1211
|
+
costs 10.8-27.9 KB per Java file, and that 2.6x spread is a real property of
|
|
1212
|
+
the input (how densely the code cross-references), not sampling noise.
|
|
1213
|
+
Publishing its midpoint as one number would assert a precision the sample
|
|
1214
|
+
does not support — the same defect C1-12 records for migration effort — so
|
|
1215
|
+
the band is published with the word "estimate" on it.
|
|
1216
|
+
|
|
1217
|
+
Pure and TTY-agnostic: the caller owns the terminal gate, so this never
|
|
1218
|
+
reaches stdout or a pipeline (C3-26).
|
|
1219
|
+
"""
|
|
1220
|
+
est_low_mb = file_count * _IR_BYTES_PER_FILE_LOW / (1024 * 1024)
|
|
1221
|
+
est_high_mb = file_count * _IR_BYTES_PER_FILE_HIGH / (1024 * 1024)
|
|
1222
|
+
if est_high_mb <= _IR_SIZE_WARN_MB:
|
|
1223
|
+
return None
|
|
1224
|
+
return (
|
|
1225
|
+
f"[repo-ir] {file_count} files — estimated output "
|
|
1226
|
+
f"{est_low_mb:.0f}-{est_high_mb:.0f}MB (estimate, not a measurement). "
|
|
1227
|
+
"To spend less: --summary-only, --max-nodes N --max-edges N, or --gzip."
|
|
1228
|
+
)
|
|
1229
|
+
|
|
1084
1230
|
|
|
1085
1231
|
def _cold_scan_advisory_message(
|
|
1086
1232
|
file_tree: dict,
|
|
@@ -4091,6 +4237,19 @@ def repo_ir_cmd(
|
|
|
4091
4237
|
)
|
|
4092
4238
|
raise typer.Exit(code=1)
|
|
4093
4239
|
|
|
4240
|
+
# C3-18: state the cost before paying it, not after. The old advisory fired
|
|
4241
|
+
# once the IR was built AND serialized — the whole bill already paid, and the
|
|
4242
|
+
# file already on disk. The estimate is a RANGE because a point would be a
|
|
4243
|
+
# measurement we do not have: sampled across four repositories the IR costs
|
|
4244
|
+
# 10.8-27.9 KB per Java file (petclinic 30 files/318 KB, jobrunr 581/9.5 MB,
|
|
4245
|
+
# openmrs-core 866/21 MB, eureka 299/8.0 MB), a 2.6x spread that depends on
|
|
4246
|
+
# how densely the code cross-references. Reporting the midpoint of that as a
|
|
4247
|
+
# single figure is the C1-12 defect.
|
|
4248
|
+
if output_path and not gzip_output:
|
|
4249
|
+
_notice_msg = _ir_size_advisory_message(len(file_list))
|
|
4250
|
+
if _notice_msg:
|
|
4251
|
+
_notice(_notice_msg)
|
|
4252
|
+
|
|
4094
4253
|
_ir_phase = f"extracting IR ({len(file_list)} files)"
|
|
4095
4254
|
if since:
|
|
4096
4255
|
_ir_phase += f" since {since}"
|
|
@@ -4113,38 +4272,32 @@ def repo_ir_cmd(
|
|
|
4113
4272
|
output_path = output_path.with_suffix(output_path.suffix + ".gz")
|
|
4114
4273
|
raw_bytes = output.encode("utf-8")
|
|
4115
4274
|
size_bytes = len(raw_bytes)
|
|
4116
|
-
_SIZE_WARN_BYTES = 10 * 1024 * 1024 # 10MB
|
|
4117
|
-
if size_bytes > _SIZE_WARN_BYTES and not gzip_output:
|
|
4118
|
-
typer.echo(
|
|
4119
|
-
f"[repo-ir] Output is {size_bytes // (1024 * 1024)}MB — "
|
|
4120
|
-
"consider --summary-only, --max-nodes N --max-edges N, or --gzip to compress.",
|
|
4121
|
-
err=True,
|
|
4122
|
-
)
|
|
4123
4275
|
if gzip_output:
|
|
4124
4276
|
import gzip as _gzip
|
|
4125
4277
|
with _gzip.open(output_path, "wb") as _gz:
|
|
4126
4278
|
_gz.write(raw_bytes)
|
|
4127
4279
|
compressed_kb = output_path.stat().st_size // 1024
|
|
4128
4280
|
size_kb = size_bytes // 1024
|
|
4281
|
+
# C3-26: when the payload went to a file, this confirmation IS the
|
|
4282
|
+
# answer of the run — the same case as `cache warm`. On stderr,
|
|
4283
|
+
# `ask repo-ir -o ir.json > log.txt` captured nothing and PowerShell
|
|
4284
|
+
# 5.1 raised NativeCommandError over a run that succeeded.
|
|
4129
4285
|
typer.echo(
|
|
4130
|
-
f"IR written to {output_path} ({compressed_kb}KB gzip, {size_kb}KB uncompressed)"
|
|
4131
|
-
err=True,
|
|
4286
|
+
f"IR written to {output_path} ({compressed_kb}KB gzip, {size_kb}KB uncompressed)"
|
|
4132
4287
|
)
|
|
4133
4288
|
else:
|
|
4134
4289
|
output_path.write_bytes(raw_bytes)
|
|
4135
4290
|
size_kb = size_bytes // 1024
|
|
4136
4291
|
if summary_only:
|
|
4137
4292
|
typer.echo(
|
|
4138
|
-
f"IR written to {output_path} ({size_kb}KB, graph omitted by --summary-only)"
|
|
4139
|
-
err=True,
|
|
4293
|
+
f"IR written to {output_path} ({size_kb}KB, graph omitted by --summary-only)"
|
|
4140
4294
|
)
|
|
4141
4295
|
else:
|
|
4142
4296
|
n_nodes = len((ir.get("graph") or {}).get("nodes") or [])
|
|
4143
4297
|
n_edges = len((ir.get("graph") or {}).get("edges") or [])
|
|
4144
4298
|
typer.echo(
|
|
4145
4299
|
f"IR written to {output_path} "
|
|
4146
|
-
f"({size_kb}KB, {n_nodes} nodes, {n_edges} edges)"
|
|
4147
|
-
err=True,
|
|
4300
|
+
f"({size_kb}KB, {n_nodes} nodes, {n_edges} edges)"
|
|
4148
4301
|
)
|
|
4149
4302
|
else:
|
|
4150
4303
|
if gzip_output:
|
|
@@ -4239,7 +4392,7 @@ def impact_cmd(
|
|
|
4239
4392
|
),
|
|
4240
4393
|
no_cache: bool = typer.Option(
|
|
4241
4394
|
False, "--no-cache",
|
|
4242
|
-
help=
|
|
4395
|
+
help=_NO_CACHE_SUBCOMMAND_HELP,
|
|
4243
4396
|
),
|
|
4244
4397
|
) -> None:
|
|
4245
4398
|
"""Blast-radius analysis: who calls this class and what breaks if it changes?
|
|
@@ -4333,8 +4486,16 @@ def impact_cmd(
|
|
|
4333
4486
|
finally:
|
|
4334
4487
|
_prog.finish()
|
|
4335
4488
|
|
|
4336
|
-
|
|
4337
|
-
|
|
4489
|
+
# `--output <file>` asks for the whole answer, and its own note promised it.
|
|
4490
|
+
# Both halves of that promise were broken: the caller lists were capped at
|
|
4491
|
+
# construction, so the file held the same 30 of 134, and the output budget
|
|
4492
|
+
# trimmed the file too — while `--agent` and `--compact` beside it already
|
|
4493
|
+
# skipped the budget when writing to a file (C2-2).
|
|
4494
|
+
_to_file = output_path is not None
|
|
4495
|
+
result = compute_blast_radius(
|
|
4496
|
+
ir, target, max_depth=depth, list_limit=0 if _to_file else None
|
|
4497
|
+
)
|
|
4498
|
+
result = _trim(result, BUDGET_IMPACT, label="impact", skip=_to_file)
|
|
4338
4499
|
if _misread and result.get("resolution") == "not_found":
|
|
4339
4500
|
result["analysis_warnings"] = [*(result.get("analysis_warnings") or []), _misread]
|
|
4340
4501
|
|
|
@@ -4422,7 +4583,7 @@ def endpoints_cmd(
|
|
|
4422
4583
|
),
|
|
4423
4584
|
no_cache: bool = typer.Option(
|
|
4424
4585
|
False, "--no-cache",
|
|
4425
|
-
help=
|
|
4586
|
+
help=_NO_CACHE_SUBCOMMAND_HELP,
|
|
4426
4587
|
),
|
|
4427
4588
|
by_controller: bool = typer.Option(
|
|
4428
4589
|
False, "--by-controller",
|
|
@@ -5047,7 +5208,7 @@ def validation_cmd(
|
|
|
5047
5208
|
),
|
|
5048
5209
|
no_cache: bool = typer.Option(
|
|
5049
5210
|
False, "--no-cache",
|
|
5050
|
-
help=
|
|
5211
|
+
help=_NO_CACHE_SUBCOMMAND_HELP,
|
|
5051
5212
|
),
|
|
5052
5213
|
) -> None:
|
|
5053
5214
|
"""Map request-body validation per endpoint (constraints + custom validators).
|
|
@@ -5910,7 +6071,7 @@ def spring_audit_cmd(
|
|
|
5910
6071
|
),
|
|
5911
6072
|
no_cache: bool = typer.Option(
|
|
5912
6073
|
False, "--no-cache",
|
|
5913
|
-
help=
|
|
6074
|
+
help=_NO_CACHE_SUBCOMMAND_HELP,
|
|
5914
6075
|
),
|
|
5915
6076
|
) -> None:
|
|
5916
6077
|
"""Spring semantic audit: TX anomalies (TX-001..006) + security surface (SEC-001..003).
|
|
@@ -6182,12 +6343,13 @@ def verify_cmd(
|
|
|
6182
6343
|
|
|
6183
6344
|
\b
|
|
6184
6345
|
Contracts live in `.ask/contracts.yml` (see `ask verify-edit --help` for the
|
|
6185
|
-
rule kinds). No contracts declared → nothing
|
|
6346
|
+
rule kinds). No contracts declared → nothing was verified, exit 2.
|
|
6186
6347
|
|
|
6187
6348
|
\b
|
|
6188
6349
|
Exit codes (with --ci, the default): 0 = pass, 1 = violations blocked,
|
|
6189
|
-
2 = unverified (contracts unreadable or the
|
|
6190
|
-
analysed — never a silent pass). Same codes as
|
|
6350
|
+
2 = unverified (no contracts declared, contracts unreadable, or the
|
|
6351
|
+
repository could not be analysed — never a silent pass). Same codes as
|
|
6352
|
+
`verify-edit`.
|
|
6191
6353
|
|
|
6192
6354
|
\b
|
|
6193
6355
|
Examples:
|
|
@@ -6451,6 +6613,24 @@ def migrate_check_cmd(
|
|
|
6451
6613
|
help="Minimum severity to include: critical, high, medium, or low (default).",
|
|
6452
6614
|
show_default=True,
|
|
6453
6615
|
),
|
|
6616
|
+
target_jdk: Optional[int] = typer.Option(
|
|
6617
|
+
None,
|
|
6618
|
+
"--target-jdk",
|
|
6619
|
+
help=(
|
|
6620
|
+
"The JDK you are actually moving to (e.g. 11, 17, 21). Rules for a "
|
|
6621
|
+
"later JDK are not your blockers and are not reported. Without it, "
|
|
6622
|
+
"every JDK axis is reported at once."
|
|
6623
|
+
),
|
|
6624
|
+
),
|
|
6625
|
+
keep_boot: bool = typer.Option(
|
|
6626
|
+
False,
|
|
6627
|
+
"--keep-boot",
|
|
6628
|
+
help=(
|
|
6629
|
+
"You are staying on Spring Boot 2.x. Drops the Boot 3 axes — the "
|
|
6630
|
+
"jakarta namespace, Spring Security 6 and the Hibernate 5→6 rewrite "
|
|
6631
|
+
"— which are not blockers for a move you are not making."
|
|
6632
|
+
),
|
|
6633
|
+
),
|
|
6454
6634
|
compact: bool = typer.Option(
|
|
6455
6635
|
False,
|
|
6456
6636
|
"--compact",
|
|
@@ -6463,7 +6643,7 @@ def migrate_check_cmd(
|
|
|
6463
6643
|
),
|
|
6464
6644
|
no_cache: bool = typer.Option(
|
|
6465
6645
|
False, "--no-cache",
|
|
6466
|
-
help=
|
|
6646
|
+
help=_NO_CACHE_SUBCOMMAND_HELP,
|
|
6467
6647
|
),
|
|
6468
6648
|
snapshot: bool = typer.Option(
|
|
6469
6649
|
False, "--snapshot",
|
|
@@ -6594,7 +6774,13 @@ def migrate_check_cmd(
|
|
|
6594
6774
|
_prog = Progress()
|
|
6595
6775
|
_prog.start(f"checking migration ({len(file_list)} files)")
|
|
6596
6776
|
try:
|
|
6597
|
-
report = run_migrate_check(
|
|
6777
|
+
report = run_migrate_check(
|
|
6778
|
+
file_list,
|
|
6779
|
+
target,
|
|
6780
|
+
min_severity=min_severity,
|
|
6781
|
+
target_jdk=target_jdk,
|
|
6782
|
+
keep_boot=keep_boot,
|
|
6783
|
+
)
|
|
6598
6784
|
finally:
|
|
6599
6785
|
_prog.finish()
|
|
6600
6786
|
if _file_limitations:
|
|
@@ -6669,6 +6855,26 @@ _IMPACT_CAPPED_LISTS = (
|
|
|
6669
6855
|
)
|
|
6670
6856
|
|
|
6671
6857
|
|
|
6858
|
+
def _flag_was_given(name: str) -> bool:
|
|
6859
|
+
"""True when the user typed this option, rather than inheriting its default.
|
|
6860
|
+
|
|
6861
|
+
A default is not a request: `--output` means "give me the whole answer", and
|
|
6862
|
+
that reading must not override a cap the user explicitly asked for on the same
|
|
6863
|
+
line. Click records where each value came from, which is the only way to tell
|
|
6864
|
+
`--limit 100` apart from the 100 nobody typed.
|
|
6865
|
+
"""
|
|
6866
|
+
try:
|
|
6867
|
+
import click
|
|
6868
|
+
|
|
6869
|
+
ctx = click.get_current_context(silent=True)
|
|
6870
|
+
if ctx is None:
|
|
6871
|
+
return False
|
|
6872
|
+
source = ctx.get_parameter_source(name)
|
|
6873
|
+
except Exception:
|
|
6874
|
+
return False
|
|
6875
|
+
return source is not None and getattr(source, "name", "") == "COMMANDLINE"
|
|
6876
|
+
|
|
6877
|
+
|
|
6672
6878
|
def _cap_impact_lists(data: dict, limit: int) -> dict:
|
|
6673
6879
|
"""Cap impact-chain leaf arrays, keeping every total exact.
|
|
6674
6880
|
|
|
@@ -6745,7 +6951,7 @@ def impact_chain_cmd(
|
|
|
6745
6951
|
),
|
|
6746
6952
|
no_cache: bool = typer.Option(
|
|
6747
6953
|
False, "--no-cache",
|
|
6748
|
-
help=
|
|
6954
|
+
help=_NO_CACHE_SUBCOMMAND_HELP,
|
|
6749
6955
|
),
|
|
6750
6956
|
limit: int = typer.Option(
|
|
6751
6957
|
100, "--limit",
|
|
@@ -6874,6 +7080,11 @@ def impact_chain_cmd(
|
|
|
6874
7080
|
result = run_impact_chain(cir, symbol, depth=depth, root=target, model=_model)
|
|
6875
7081
|
|
|
6876
7082
|
data = result.to_dict()
|
|
7083
|
+
# Same rule as `impact`: a file is a request for the whole answer (C2-2). An
|
|
7084
|
+
# explicit `--limit` still wins — the user asking for a cap on a file has said
|
|
7085
|
+
# so, and a default is not a request.
|
|
7086
|
+
if output_path is not None and not _flag_was_given("limit"):
|
|
7087
|
+
limit = 0
|
|
6877
7088
|
data = _cap_impact_lists(data, limit)
|
|
6878
7089
|
if _misread and result.resolution == "not_found":
|
|
6879
7090
|
data["analysis_warnings"] = [*(data.get("analysis_warnings") or []), _misread]
|
|
@@ -7821,7 +8032,9 @@ def modernize_cmd(
|
|
|
7821
8032
|
|
|
7822
8033
|
if output_path:
|
|
7823
8034
|
_safe_write_file(output_path, output)
|
|
7824
|
-
|
|
8035
|
+
# C3-26: the payload is in the file; this line is the answer (see
|
|
8036
|
+
# _emit_command_output, which this branch predates).
|
|
8037
|
+
typer.echo(f"Modernization analysis written to {output_path}")
|
|
7825
8038
|
else:
|
|
7826
8039
|
try:
|
|
7827
8040
|
sys.stdout.buffer.write(output.encode("utf-8"))
|
|
@@ -8775,16 +8988,17 @@ def cold_start_cmd(
|
|
|
8775
8988
|
_meta_cs["token_economy"] = _tok_econ(_out, _out_with_meta, target)
|
|
8776
8989
|
_out = _json.dumps(_out_with_meta, indent=2, ensure_ascii=False)
|
|
8777
8990
|
if not compact and _size > 400_000:
|
|
8778
|
-
|
|
8991
|
+
# Informational: the answer is still on stdout, so this is a notice and
|
|
8992
|
+
# takes the terminal gate (C3-26).
|
|
8993
|
+
_notice(
|
|
8779
8994
|
f"WARNING: Output is ~{_tokens // 1000}K tokens. This exceeds the context window of "
|
|
8780
8995
|
"most LLMs (GPT-4o: 128K, Claude Sonnet: 200K). "
|
|
8781
|
-
"Use --compact for a ~10K token subset, or --output FILE to save
|
|
8996
|
+
"Use --compact for a ~10K token subset, or --output FILE to save."
|
|
8782
8997
|
)
|
|
8783
|
-
sys.stderr.flush()
|
|
8784
8998
|
if output_path:
|
|
8785
8999
|
_safe_write_file(output_path, _out)
|
|
8786
|
-
|
|
8787
|
-
|
|
9000
|
+
# C3-26: payload in the file, so this line is the answer — stdout.
|
|
9001
|
+
typer.echo(f"Saved {len(_out.encode('utf-8'))} bytes to {output_path}")
|
|
8788
9002
|
else:
|
|
8789
9003
|
typer.echo(_out)
|
|
8790
9004
|
|
sourcecode/compare.py
CHANGED
|
@@ -39,24 +39,76 @@ COMPARE_SCHEMA: str = "candidate-comparison-v1"
|
|
|
39
39
|
_UNRESOLVED: frozenset[str] = frozenset({"not_found", "ambiguous_path"})
|
|
40
40
|
|
|
41
41
|
|
|
42
|
+
def _measured_count(blast: dict, stat_key: str, list_key: str) -> tuple[int, bool]:
|
|
43
|
+
"""(magnitude, is_floor) for one cost field.
|
|
44
|
+
|
|
45
|
+
C2-1: three fields of the ranking vector were `len()` of a *display* list.
|
|
46
|
+
Those lists are a rendering choice (`list_limit`), so the ranking key — the
|
|
47
|
+
entire product of this command — was computed over however many rows the
|
|
48
|
+
renderer had decided to show. The count taken before any cap lives in
|
|
49
|
+
`stats`, which is what the `why` trail was already corrected to read (C1-16).
|
|
50
|
+
|
|
51
|
+
A `stats` value of `None` is the C1-13 case: the walk was capped and never
|
|
52
|
+
looked, so the true magnitude is unknown. The ranking still has to be a
|
|
53
|
+
total order, so the list length is used as a **floor** and the field is
|
|
54
|
+
reported as one — never silently rendered as a confident 0, which would rank
|
|
55
|
+
a hub as the cheapest candidate on the axis the cap blinded.
|
|
56
|
+
"""
|
|
57
|
+
stat = (blast.get("stats") or {}).get(stat_key)
|
|
58
|
+
if isinstance(stat, int):
|
|
59
|
+
return stat, False
|
|
60
|
+
return len(blast.get(list_key) or []), True
|
|
61
|
+
|
|
62
|
+
|
|
42
63
|
def _cost_vector(plan: dict, blast: dict) -> dict[str, int]:
|
|
43
64
|
"""Aggregate the measured cost vector for one candidate from D3 + blast primitives.
|
|
44
65
|
|
|
45
66
|
Every field is a non-negative count of a real architectural consequence. No
|
|
46
67
|
weighting, no score — the magnitudes are reported as-is; ordering happens in
|
|
47
68
|
`_rank_key` over these same magnitudes."""
|
|
69
|
+
modules, modules_floor = _measured_count(
|
|
70
|
+
blast, "modules_affected_count", "cross_module_impact"
|
|
71
|
+
)
|
|
72
|
+
secured, secured_floor = _measured_count(
|
|
73
|
+
blast, "security_surface_count", "security_surface_affected"
|
|
74
|
+
)
|
|
75
|
+
txn, txn_floor = _measured_count(
|
|
76
|
+
blast, "transactional_boundaries_count", "transactional_boundaries_touched"
|
|
77
|
+
)
|
|
48
78
|
return {
|
|
49
79
|
# Production reverse-dependency closure size (same set D3 lists as components).
|
|
50
80
|
"blast_radius": int(plan.get("affected_components", {}).get("count", 0)),
|
|
51
81
|
"affected_endpoints": int(plan.get("affected_endpoints", {}).get("count", 0)),
|
|
52
82
|
"tests_at_risk": int(plan.get("covering_tests", {}).get("count", 0)),
|
|
53
83
|
"rollback_files": int(plan.get("rollback_surface", {}).get("file_count", 0)),
|
|
54
|
-
"modules_spanned":
|
|
55
|
-
"secured_endpoints":
|
|
56
|
-
"transactional_boundaries":
|
|
84
|
+
"modules_spanned": modules,
|
|
85
|
+
"secured_endpoints": secured,
|
|
86
|
+
"transactional_boundaries": txn,
|
|
57
87
|
}
|
|
58
88
|
|
|
59
89
|
|
|
90
|
+
def _floor_fields(blast: dict) -> list[str]:
|
|
91
|
+
"""Cost fields whose magnitude is a floor, not a measurement (C1-13).
|
|
92
|
+
|
|
93
|
+
Kept beside `cost` rather than inside it: `cost` is a map of name to
|
|
94
|
+
magnitude, and putting a list in it would make the same key hold two types
|
|
95
|
+
depending on the run — the C2-10 defect this codebase just closed.
|
|
96
|
+
"""
|
|
97
|
+
return sorted(
|
|
98
|
+
name
|
|
99
|
+
for name, (_, is_floor) in (
|
|
100
|
+
("modules_spanned", _measured_count(
|
|
101
|
+
blast, "modules_affected_count", "cross_module_impact")),
|
|
102
|
+
("secured_endpoints", _measured_count(
|
|
103
|
+
blast, "security_surface_count", "security_surface_affected")),
|
|
104
|
+
("transactional_boundaries", _measured_count(
|
|
105
|
+
blast, "transactional_boundaries_count",
|
|
106
|
+
"transactional_boundaries_touched")),
|
|
107
|
+
)
|
|
108
|
+
if is_floor
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
60
112
|
def _rank_key(cand: dict) -> tuple:
|
|
61
113
|
"""Deterministic total order: resolved-first, then descending measured cost.
|
|
62
114
|
|
|
@@ -131,13 +183,26 @@ def build_comparison(
|
|
|
131
183
|
# Unresolved targets carry a zeroed cost so the table stays rectangular;
|
|
132
184
|
# they are ranked last and their evidence says why they didn't resolve.
|
|
133
185
|
cost = {k: 0 for k in cost}
|
|
134
|
-
|
|
186
|
+
candidate = {
|
|
135
187
|
"target": target,
|
|
136
188
|
"resolution": resolution,
|
|
137
189
|
"resolved": resolved,
|
|
138
190
|
"cost": cost,
|
|
139
191
|
"why": _evidence_trail(cost, blast, resolved, plan),
|
|
140
|
-
}
|
|
192
|
+
}
|
|
193
|
+
# C1-13/C2-1: when the walk was capped, some magnitudes are floors. The
|
|
194
|
+
# ranking still needs a total order, so a floor is used — but a reader
|
|
195
|
+
# comparing candidates has to know which side of the comparison is a
|
|
196
|
+
# lower bound, or the ordering reads as measured when it is partly not.
|
|
197
|
+
_floors = _floor_fields(blast) if resolved else []
|
|
198
|
+
if _floors:
|
|
199
|
+
candidate["cost_floor_fields"] = _floors
|
|
200
|
+
candidate["cost_floor_note"] = (
|
|
201
|
+
"The walk was capped before these magnitudes were complete, so "
|
|
202
|
+
"they are lower bounds and this candidate may rank lower than "
|
|
203
|
+
"its true cost. Every other field is measured."
|
|
204
|
+
)
|
|
205
|
+
candidates.append(candidate)
|
|
141
206
|
|
|
142
207
|
candidates.sort(key=_rank_key)
|
|
143
208
|
for i, cand in enumerate(candidates, start=1):
|