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 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__ = "3.5.0"
7
+ __version__ = "3.6.0"
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
- typer.echo(success_msg, err=True)
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
- typer.echo("✓ copied to clipboard", err=True)
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 = str((getattr(self, "option_name", None) or getattr(self, "param_hint", None)) or "").strip("'\"")
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="Accepted for compatibility; this command always reads fresh source (no snapshot cache). No-op.",
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
- result = compute_blast_radius(ir, target, max_depth=depth)
4337
- result = _trim(result, BUDGET_IMPACT, label="impact")
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="Accepted for compatibility; this command always reads fresh source (no snapshot cache). No-op.",
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="Accepted for compatibility; this command always reads fresh source (no snapshot cache). No-op.",
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="Accepted for compatibility; this command always reads fresh source (no snapshot cache). No-op.",
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 to verify, exit 0.
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 repository could not be
6190
- analysed — never a silent pass). Same codes as `verify-edit`.
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="Accepted for compatibility; this command always reads fresh source (no snapshot cache). No-op.",
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(file_list, target, min_severity=min_severity)
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="Accepted for compatibility; this command always reads fresh source (no snapshot cache). No-op.",
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
- typer.echo(f"Modernization analysis written to {output_path}", err=True)
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
- sys.stderr.write(
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.\n"
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
- sys.stderr.write(f"Saved {len(_out.encode('utf-8'))} bytes to {output_path}\n")
8787
- sys.stderr.flush()
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": len(blast.get("cross_module_impact") or []),
55
- "secured_endpoints": len(blast.get("security_surface_affected") or []),
56
- "transactional_boundaries": len(blast.get("transactional_boundaries_touched") or []),
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
- candidates.append({
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):