sourcecode 3.6.0__py3-none-any.whl → 3.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 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.6.0"
7
+ __version__ = "3.7.0"
sourcecode/change_plan.py CHANGED
@@ -88,7 +88,11 @@ def build_change_plan(
88
88
  resolution = blast.get("resolution", "not_found")
89
89
 
90
90
  # Unresolvable target → return a plan-shaped resolution notice (never crash).
91
- if resolution in ("not_found", "ambiguous_path") or not blast.get("matched_fqns"):
91
+ # The set of resolutions that means "never found it" is one fact, and the CLI
92
+ # reads the same one to decide the exit code (C3-29).
93
+ from sourcecode.target_admission import is_unresolved
94
+
95
+ if is_unresolved(resolution) or not blast.get("matched_fqns"):
92
96
  return {
93
97
  "schema": CHANGE_PLAN_SCHEMA,
94
98
  "target": target,
sourcecode/cli.py CHANGED
@@ -238,6 +238,64 @@ def _tier_help_block() -> str:
238
238
  return "\n".join(lines)
239
239
 
240
240
 
241
+ #: Command groups whose subcommands `--help` names one by one. A group that is
242
+ #: not listed here still appears — as its exact subcommand count and a pointer to
243
+ #: its own `--help` — so nothing can go missing; `retrieve` publishes 29 parked
244
+ #: intents, and naming them here would bury the blocks a reader acts on.
245
+ _GROUPS_LISTED_IN_HELP = ("cache", "auth", "mcp", "telemetry", "baseline")
246
+
247
+
248
+ def _subcommand_summary(entry: Any) -> str:
249
+ """The one-line description a subcommand publishes about itself."""
250
+ text = str(getattr(entry, "short_help", None) or getattr(entry, "help", None) or "")
251
+ if not text:
252
+ callback = getattr(entry, "callback", None)
253
+ text = str(getattr(callback, "__doc__", "") or "")
254
+ line = text.strip().splitlines()[0].strip() if text.strip() else ""
255
+ line = line.rstrip(".")
256
+ # Kept short so the block still fits an 80-column terminal, where a clipped
257
+ # line would hide the very fact this block exists to publish.
258
+ return line if len(line) <= 46 else line[:45].rstrip() + "…"
259
+
260
+
261
+ def _group_help_block() -> str:
262
+ """The command groups as `--help` prints them — generated from the registered
263
+ groups, never typed out.
264
+
265
+ C4-1 and C4-2 replaced the hand-written catalogue at the *root*, and the same
266
+ omission survived one level down (C4-7): `cache model` — the per-layer answer
267
+ to "what does a warm buy this command" — was in no help text at all, because
268
+ the Cache block was three lines somebody wrote by hand and never revisited.
269
+ A group's subcommands are therefore read from the registry: a new one appears
270
+ in `--help` the moment it is registered, or the group states its own count.
271
+ """
272
+ registered = globals().get("app")
273
+ groups = list(getattr(registered, "registered_groups", ()) or ())
274
+ if not groups:
275
+ # Import-time call, before any group is registered. The real `--help`
276
+ # render refreshes this text (see `_get_command_with_preprocessing`).
277
+ return ""
278
+
279
+ lines = ["[bold]Command groups:[/bold]"]
280
+ for group in groups:
281
+ name = str(getattr(group, "name", "") or "")
282
+ subcommands = list(
283
+ getattr(getattr(group, "typer_instance", None), "registered_commands", ()) or ()
284
+ )
285
+ if name in _GROUPS_LISTED_IN_HELP:
286
+ for sub in subcommands:
287
+ label = f"{name} {sub.name}"
288
+ lines.append(f" {label:<29}[dim]# {_subcommand_summary(sub)}[/dim]")
289
+ else:
290
+ tier = command_tier(name)
291
+ _tier_note = f", {tier}" if tier in ("experimental", "parked") else ""
292
+ lines.append(
293
+ f" {name + ' <subcommand>':<29}"
294
+ f"[dim]# {len(subcommands)} subcommands{_tier_note} — ask {name} --help[/dim]"
295
+ )
296
+ return "\n".join(lines)
297
+
298
+
241
299
  def _build_help_text() -> str:
242
300
  """Build --help text dynamically based on current license state."""
243
301
  try:
@@ -284,16 +342,7 @@ of files) in minutes. Semantic analysis itself is sub-second; repo indexing domi
284
342
 
285
343
  {_tier_help_block()}
286
344
 
287
- [bold]Auth commands:[/bold]
288
- auth status [dim]# show current plan and auth state[/dim]
289
- auth logout [dim]# remove local credentials[/dim]
290
-
291
- [bold]Cache commands:[/bold]
292
- cache status [dim]# cache size, hit keys, last-warmed timestamp[/dim]
293
- cache warm [dim]# pre-build structural layers + compact view
294
- # (--agent warms the agent view too; --full,
295
- # --env-map and raised --depth are NOT warmed)[/dim]
296
- cache clear [dim]# clear all cached results for this repo[/dim]
345
+ {_group_help_block()}
297
346
 
298
347
  [bold]Examples:[/bold]
299
348
  ask posture . --diff default:prod -o posture.json
@@ -305,12 +354,6 @@ of files) in minutes. Semantic analysis itself is sub-second; repo indexing domi
305
354
 
306
355
  [bold]Subcommands:[/bold]
307
356
  prepare-context TASK [PATH] [dim]# task-specific context (onboard, delta, fix-bug, ...)[/dim]
308
- mcp init [dim]# setup MCP integration (Claude Desktop, Cursor)[/dim]
309
- mcp status [dim]# show MCP integration status[/dim]
310
- mcp remove [dim]# remove MCP integration safely[/dim]
311
- mcp serve [dim]# start MCP server for AI agent integration[/dim]
312
- telemetry status|enable|disable
313
- version
314
357
  """
315
358
 
316
359
  if not _is_pro:
@@ -674,6 +717,53 @@ def _notice(message: str) -> None:
674
717
  typer.echo(message, err=True)
675
718
 
676
719
 
720
+ def _apply_budget(
721
+ data: dict,
722
+ budget_bytes: int,
723
+ *,
724
+ label: str,
725
+ output_path: "Optional[Path]",
726
+ ) -> dict:
727
+ """Apply the payload budget — the one seam every command goes through.
728
+
729
+ The budget exists because an agent's context window is finite, so it is a
730
+ constraint on **stdout** and on nothing else. When `--output FILE` is given
731
+ the payload does not pass through a context window at all, and cutting it
732
+ there breaks the contract twice over: the answer is silently sampled, and
733
+ the note explaining the cut advises `--output` — the flag already in use
734
+ (C3-17). Two of the six call sites had the rule and four did not, which is
735
+ the shape this seam removes: one place decides, every command inherits it.
736
+ """
737
+ from sourcecode.output_budget import trim_to_budget as _trim
738
+
739
+ to_file = output_path is not None
740
+ return _trim(
741
+ data, budget_bytes, label=label, skip=to_file, warn_stderr=not to_file
742
+ )
743
+
744
+
745
+ def _prepare_context_budget(task: str) -> int:
746
+ """The stdout budget for a `prepare-context` task — one table, two readers.
747
+
748
+ The fresh path and the cache-hit path both budget the payload, and a second
749
+ copy of this mapping beside the first is exactly how the two drift apart.
750
+ """
751
+ from sourcecode.output_budget import (
752
+ BUDGET_DELTA, BUDGET_EXPLAIN, BUDGET_FIX_BUG,
753
+ BUDGET_ONBOARD, BUDGET_REFACTOR, BUDGET_REVIEW_PR,
754
+ )
755
+
756
+ return {
757
+ "fix-bug": BUDGET_FIX_BUG,
758
+ "review-pr": BUDGET_REVIEW_PR,
759
+ "onboard": BUDGET_ONBOARD,
760
+ "explain": BUDGET_EXPLAIN,
761
+ "refactor": BUDGET_REFACTOR,
762
+ "delta": BUDGET_DELTA,
763
+ "generate-tests": BUDGET_EXPLAIN,
764
+ }.get(task, BUDGET_EXPLAIN)
765
+
766
+
677
767
  def _admit_path(
678
768
  raw: "Path | str",
679
769
  *,
@@ -713,6 +803,21 @@ def _admit_path(
713
803
  raise typer.Exit(code=exit_code)
714
804
 
715
805
 
806
+ def _exit_on_unresolved_target(resolution: "Optional[str]") -> None:
807
+ """Close the run on a target the analysis never resolved — one exit code.
808
+
809
+ The payload has already been emitted and it is truthful: it names the
810
+ resolution and lists nothing. What was inconsistent is the code a pipeline
811
+ reads — `impact` said 1, `plan` said 0 over the same fact (C3-29) — so the
812
+ decision belongs to `target_admission`, not to each command.
813
+ """
814
+ from sourcecode.target_admission import exit_code_for
815
+
816
+ code = exit_code_for(resolution)
817
+ if code:
818
+ raise typer.Exit(code=code)
819
+
820
+
716
821
  def _misread_path_note(symbol: str, analysed_root: "Path", usage: str) -> "Optional[str]":
717
822
  """State the repository actually analysed when the symbol argument holds a path.
718
823
 
@@ -1518,7 +1623,11 @@ def main(
1518
1623
  full: bool = typer.Option(
1519
1624
  False,
1520
1625
  "--full",
1521
- help="Remove truncation limits on transactional_boundaries, mybatis.dto_mappers, and other capped lists.",
1626
+ help=(
1627
+ "Raise the display caps: transactional_boundaries and mybatis.dto_mappers "
1628
+ "are listed in full, file_relevance rises to 40 entries. Other caps stay, "
1629
+ "and each says what it cut; --output FILE skips the payload budget entirely."
1630
+ ),
1522
1631
  ),
1523
1632
  trace_pipeline: bool = typer.Option(
1524
1633
  False,
@@ -2146,17 +2255,15 @@ def main(
2146
2255
  _rebuilt = _red_l1(_rebuilt)
2147
2256
  # Apply output budget
2148
2257
  if agent:
2149
- from sourcecode.output_budget import (
2150
- trim_to_budget as _trim_l1,
2151
- BUDGET_AGENT,
2258
+ from sourcecode.output_budget import BUDGET_AGENT
2259
+ _rebuilt = _apply_budget(
2260
+ _rebuilt, BUDGET_AGENT, label="agent", output_path=output
2152
2261
  )
2153
- _rebuilt = _trim_l1(_rebuilt, BUDGET_AGENT, label="agent")
2154
2262
  elif compact:
2155
- from sourcecode.output_budget import (
2156
- trim_to_budget as _trim_l1c,
2157
- BUDGET_COMPACT,
2263
+ from sourcecode.output_budget import BUDGET_COMPACT
2264
+ _rebuilt = _apply_budget(
2265
+ _rebuilt, BUDGET_COMPACT, label="compact", output_path=output
2158
2266
  )
2159
- _rebuilt = _trim_l1c(_rebuilt, BUDGET_COMPACT, label="compact")
2160
2267
  # Serialize
2161
2268
  _cache_hit_content = _serialize_dict(_rebuilt, format)
2162
2269
  # Cache rebuilt view in L2 (skip for --changed-only: stale diff)
@@ -2315,11 +2422,12 @@ def main(
2315
2422
  # IMP-2: warn if the exclude value looks like it was swallowed as a path
2316
2423
  # (BUG-2 symptom in older versions: --exclude value consumed as repo path).
2317
2424
  if len(_extra_excludes) == 1 and Path(list(_extra_excludes)[0]).is_dir():
2318
- sys.stderr.write(
2425
+ # C3-34: same rule as every other advisory — the run succeeds, so
2426
+ # this speaks to a person at a terminal and never to a pipe.
2427
+ _notice(
2319
2428
  f"[sourcecode] Warning: --exclude value '{list(_extra_excludes)[0]}' is a directory path. "
2320
- "If this was meant as a pattern, use --exclude=pattern or --exclude pattern (both are supported).\n"
2429
+ "If this was meant as a pattern, use --exclude=pattern or --exclude pattern (both are supported)."
2321
2430
  )
2322
- sys.stderr.flush()
2323
2431
 
2324
2432
  _progress = Progress()
2325
2433
  _progress.start("scanning files")
@@ -3065,8 +3173,8 @@ def main(
3065
3173
  data = redact_dict(data)
3066
3174
  # P0-1: Apply output budget — safety net for large repos.
3067
3175
  # Skip budget when writing to a file (no size constraint); warn on stdout.
3068
- from sourcecode.output_budget import trim_to_budget as _trim, BUDGET_AGENT
3069
- data = _trim(data, BUDGET_AGENT, label="agent", skip=(output is not None), warn_stderr=(output is None))
3176
+ from sourcecode.output_budget import BUDGET_AGENT
3177
+ data = _apply_budget(data, BUDGET_AGENT, label="agent", output_path=output)
3070
3178
  # FIX-P0-2: agent mode must honour --format yaml (previously always emitted JSON).
3071
3179
  content = _serialize_dict(data, format)
3072
3180
  elif compact:
@@ -3087,8 +3195,8 @@ def main(
3087
3195
  data = redact_dict(data)
3088
3196
  # P0-1: Apply output budget — safety net for large repos.
3089
3197
  # Skip budget when writing to a file (no size constraint); warn on stdout.
3090
- from sourcecode.output_budget import trim_to_budget as _trim_c, BUDGET_COMPACT
3091
- data = _trim_c(data, BUDGET_COMPACT, label="compact", skip=(output is not None), warn_stderr=(output is None))
3198
+ from sourcecode.output_budget import BUDGET_COMPACT
3199
+ data = _apply_budget(data, BUDGET_COMPACT, label="compact", output_path=output)
3092
3200
  content = _serialize_dict(data, format)
3093
3201
  else:
3094
3202
  raw_dict = standard_view(sm, include_tree=tree and not no_tree)
@@ -3605,6 +3713,22 @@ def prepare_context_cmd(
3605
3713
  _pctx_cache_key = f"pctx-{task}-{_pctx_git_sha}-{_sym_h}-{format or 'json'}"
3606
3714
  _cached_pctx = _pctx_cache.read(target, _pctx_cache_key)
3607
3715
  if _cached_pctx is not None:
3716
+ # Cached documents are stored pre-budget: a hit that reused the
3717
+ # previous run's trimming would send a stdout-sized answer to a
3718
+ # file, which is the defect the budget seam exists to stop (C3-17).
3719
+ try:
3720
+ _cached_pctx = json.dumps(
3721
+ _apply_budget(
3722
+ json.loads(_cached_pctx),
3723
+ _prepare_context_budget(task),
3724
+ label=task,
3725
+ output_path=output_path,
3726
+ ),
3727
+ indent=2,
3728
+ ensure_ascii=False,
3729
+ )
3730
+ except Exception:
3731
+ pass # not JSON (or unreadable) → serve exactly what was stored
3608
3732
  _emit_command_output(_cached_pctx, output_path, copy)
3609
3733
  try:
3610
3734
  from sourcecode import telemetry as _tel
@@ -3958,22 +4082,7 @@ def prepare_context_cmd(
3958
4082
  out["skipped_analyzers"] = _skipped
3959
4083
 
3960
4084
  # P0-1: Apply output budget per task — safety net for large repos.
3961
- from sourcecode.output_budget import (
3962
- trim_to_budget as _pc_trim,
3963
- BUDGET_FIX_BUG, BUDGET_REVIEW_PR, BUDGET_ONBOARD,
3964
- BUDGET_EXPLAIN, BUDGET_REFACTOR, BUDGET_DELTA,
3965
- )
3966
- _pc_budgets: dict[str, int] = {
3967
- "fix-bug": BUDGET_FIX_BUG,
3968
- "review-pr": BUDGET_REVIEW_PR,
3969
- "onboard": BUDGET_ONBOARD,
3970
- "explain": BUDGET_EXPLAIN,
3971
- "refactor": BUDGET_REFACTOR,
3972
- "delta": BUDGET_DELTA,
3973
- "generate-tests": BUDGET_EXPLAIN,
3974
- }
3975
- _pc_budget = _pc_budgets.get(task, BUDGET_EXPLAIN)
3976
- out = _pc_trim(out, _pc_budget, label=task)
4085
+ _pc_budget = _prepare_context_budget(task)
3977
4086
 
3978
4087
  # Size-gated preview: on enterprise-scale monoliths free users get a capped
3979
4088
  # preview (top-5 / lightweight) + upgrade note. Small/mid repos get the full
@@ -4002,6 +4111,13 @@ def prepare_context_cmd(
4002
4111
  "complete execution paths, and CI-grade risk scoring."
4003
4112
  )
4004
4113
 
4114
+ # The cache stores the analysis, never the rendering. The budget depends on
4115
+ # where this particular run sends its payload (C3-17), so caching the trimmed
4116
+ # document would serve a stdout-sized answer to the next `--output FILE` run —
4117
+ # the defect surviving one layer down. Written pre-budget, budgeted per run.
4118
+ _pc_cacheable_content = json.dumps(out, indent=2, ensure_ascii=False)
4119
+ out = _apply_budget(out, _pc_budget, label=task, output_path=output_path)
4120
+
4005
4121
  if format == "github-comment" and task == "review-pr":
4006
4122
  from sourcecode.pr_comment_renderer import render_github_comment
4007
4123
  _pc_content = render_github_comment(out)
@@ -4010,7 +4126,7 @@ def prepare_context_cmd(
4010
4126
 
4011
4127
  if _pctx_cacheable and _pctx_cache_key and format != "github-comment":
4012
4128
  try:
4013
- _pctx_cache.write(target, _pctx_cache_key, _pc_content)
4129
+ _pctx_cache.write(target, _pctx_cache_key, _pc_cacheable_content)
4014
4130
  except Exception:
4015
4131
  pass
4016
4132
 
@@ -4330,17 +4446,17 @@ def repo_ir_cmd(
4330
4446
  )
4331
4447
  raise typer.Exit(1)
4332
4448
  if _ir_tokens_est > 10_000:
4333
- if summary_only:
4334
- sys.stderr.write(
4335
- f"[repo-ir] ~{_ir_tokens_est // 1000}K tokens — "
4336
- "use --max-nodes N --max-edges N or --output FILE for smaller output.\n"
4337
- )
4338
- else:
4339
- sys.stderr.write(
4340
- f"[repo-ir] ~{_ir_tokens_est // 1000}K tokens — "
4341
- "use --summary-only or --output FILE for smaller output.\n"
4342
- )
4343
- sys.stderr.flush()
4449
+ # C3-34: the run succeeded and the IR is on stdout, so this advisory
4450
+ # is a note to a person — on a pipe it is noise, and on PowerShell
4451
+ # 5.1 it turns a successful `ask repo-ir --force > ir.json` into a
4452
+ # NativeCommandError. Terminal-gated like every other notice.
4453
+ _smaller = (
4454
+ "--max-nodes N --max-edges N" if summary_only else "--summary-only"
4455
+ )
4456
+ _notice(
4457
+ f"[repo-ir] ~{_ir_tokens_est // 1000}K tokens "
4458
+ f"use {_smaller} or --output FILE for smaller output."
4459
+ )
4344
4460
  _emit_command_output(output, None, copy)
4345
4461
 
4346
4462
 
@@ -4428,7 +4544,7 @@ def impact_cmd(
4428
4544
  from sourcecode.repository_ir import (
4429
4545
  build_repo_ir, find_java_files, compute_blast_radius,
4430
4546
  )
4431
- from sourcecode.output_budget import trim_to_budget as _trim, BUDGET_IMPACT
4547
+ from sourcecode.output_budget import BUDGET_IMPACT
4432
4548
 
4433
4549
  # Legacy-compat: old syntax was `impact <path> <target>`.
4434
4550
  # Detect: target resolves to an existing directory (not a class name), and
@@ -4495,7 +4611,7 @@ def impact_cmd(
4495
4611
  result = compute_blast_radius(
4496
4612
  ir, target, max_depth=depth, list_limit=0 if _to_file else None
4497
4613
  )
4498
- result = _trim(result, BUDGET_IMPACT, label="impact", skip=_to_file)
4614
+ result = _apply_budget(result, BUDGET_IMPACT, label="impact", output_path=output_path)
4499
4615
  if _misread and result.get("resolution") == "not_found":
4500
4616
  result["analysis_warnings"] = [*(result.get("analysis_warnings") or []), _misread]
4501
4617
 
@@ -4503,8 +4619,7 @@ def impact_cmd(
4503
4619
  _emit_command_output(output, output_path, copy,
4504
4620
  success_msg=f"Impact analysis written to {output_path}")
4505
4621
 
4506
- if result.get("resolution") == "not_found":
4507
- raise typer.Exit(code=1)
4622
+ _exit_on_unresolved_target(result.get("resolution"))
4508
4623
 
4509
4624
  from sourcecode.mcp_nudge import nudge_mcp_if_needed as _nudge
4510
4625
  _nudge()
@@ -5545,9 +5660,6 @@ def plan_cmd(
5545
5660
  data = build_change_plan(_cir, target, max_depth=depth)
5546
5661
  _prog.finish()
5547
5662
 
5548
- if data.get("resolution") in ("not_found", "ambiguous_path"):
5549
- typer.echo(f"Note: {data.get('message', 'target not resolved')}", err=True)
5550
-
5551
5663
  output = _serialize_dict(data, format)
5552
5664
  _emit_command_output(
5553
5665
  output, output_path, copy,
@@ -5559,6 +5671,12 @@ def plan_cmd(
5559
5671
  ),
5560
5672
  )
5561
5673
 
5674
+ # C3-29: the payload already names the resolution and lists nothing; the
5675
+ # `Note:` on stderr that used to stand here said the same thing a second
5676
+ # time on a run that exited 0, so a gate reading the exit code was told the
5677
+ # plan succeeded. The exit code now carries it, and stderr stays clean.
5678
+ _exit_on_unresolved_target(data.get("resolution"))
5679
+
5562
5680
 
5563
5681
  # ── D4 Candidate Comparison ───────────────────────────────────────────────────
5564
5682
 
@@ -6069,6 +6187,15 @@ def spring_audit_cmd(
6069
6187
  "--ci/--no-ci",
6070
6188
  help="Exit with code 1 if any findings at or above --min-severity are found. For CI/CD gates.",
6071
6189
  ),
6190
+ compact: bool = typer.Option(
6191
+ False,
6192
+ "--compact",
6193
+ help=(
6194
+ "Bounded summary: caps `findings` and the per-endpoint security projection "
6195
+ "to their top 5, most-severe first. Every count is still measured over the "
6196
+ "whole population. The full report can exceed 1 MB on a monolith."
6197
+ ),
6198
+ ),
6072
6199
  no_cache: bool = typer.Option(
6073
6200
  False, "--no-cache",
6074
6201
  help=_NO_CACHE_SUBCOMMAND_HELP,
@@ -6104,6 +6231,7 @@ def spring_audit_cmd(
6104
6231
  ask spring-audit /path/to/repo
6105
6232
  ask spring-audit . --scope security
6106
6233
  ask spring-audit . --min-severity high
6234
+ ask spring-audit . --compact
6107
6235
  ask spring-audit . --output audit.json
6108
6236
  """
6109
6237
  import json as _json
@@ -6182,7 +6310,7 @@ def spring_audit_cmd(
6182
6310
  except Exception:
6183
6311
  pass
6184
6312
 
6185
- data = combined.to_dict()
6313
+ data = combined.to_compact_dict() if compact else combined.to_dict()
6186
6314
 
6187
6315
  # Non-fatal RIS side-effect — persist summary only (not full findings).
6188
6316
  try:
@@ -7100,8 +7228,7 @@ def impact_chain_cmd(
7100
7228
  ),
7101
7229
  )
7102
7230
 
7103
- if result.resolution == "not_found":
7104
- raise typer.Exit(code=1)
7231
+ _exit_on_unresolved_target(result.resolution)
7105
7232
 
7106
7233
 
7107
7234
  # ── PR Impact Report ──────────────────────────────────────────────────────────
@@ -7418,8 +7545,11 @@ def explain_cmd(
7418
7545
  _emit_command_output(output, output_path, copy,
7419
7546
  success_msg=f"Explanation written to {output_path}")
7420
7547
 
7421
- if not explanation.found:
7422
- raise typer.Exit(code=1)
7548
+ # `explain` reports a flag where the others report a resolution; it maps onto
7549
+ # the same vocabulary rather than deriving its own code beside the authority.
7550
+ from sourcecode.target_admission import resolution_for_found_flag
7551
+
7552
+ _exit_on_unresolved_target(resolution_for_found_flag(explanation.found))
7423
7553
 
7424
7554
 
7425
7555
  # ── Enterprise Workflow Commands ──────────────────────────────────────────────
@@ -7722,7 +7852,7 @@ def modernize_cmd(
7722
7852
  """
7723
7853
  import json as _json
7724
7854
  from sourcecode.repository_ir import build_repo_ir, find_java_files, apply_ir_size_limits
7725
- from sourcecode.output_budget import trim_to_budget, BUDGET_ONBOARD
7855
+ from sourcecode.output_budget import BUDGET_ONBOARD
7726
7856
  from sourcecode.license import is_pro as _mod_is_pro, is_large_repo as _mod_large
7727
7857
 
7728
7858
  root = _admit_path(path)
@@ -8027,7 +8157,7 @@ def modernize_cmd(
8027
8157
  ),
8028
8158
  }
8029
8159
 
8030
- result = trim_to_budget(result, BUDGET_ONBOARD, label="modernize")
8160
+ result = _apply_budget(result, BUDGET_ONBOARD, label="modernize", output_path=output_path)
8031
8161
  output = _json.dumps(result, indent=2, ensure_ascii=False)
8032
8162
 
8033
8163
  if output_path:
@@ -9570,7 +9700,7 @@ def _warm_shared_cir(target: Path):
9570
9700
  return None
9571
9701
 
9572
9702
 
9573
- @cache_app.command("warm")
9703
+ @cache_app.command("warm", short_help="Pre-build the cache: structural layers + compact view")
9574
9704
  def cache_warm_cmd(
9575
9705
  path: Path = typer.Argument(Path("."), help="Repository path to warm (default: current directory)"),
9576
9706
  compact: bool = typer.Option(True, "--compact/--no-compact", help="Warm compact view (default: on)."),
@@ -9662,7 +9792,7 @@ def cache_warm_cmd(
9662
9792
  )
9663
9793
 
9664
9794
 
9665
- @cache_app.command("model")
9795
+ @cache_app.command("model", short_help="What a warm buys each command, per cache layer")
9666
9796
  def cache_model_cmd(
9667
9797
  json_output: bool = typer.Option(False, "--json", help="Output as JSON."),
9668
9798
  markdown: bool = typer.Option(False, "--markdown", help="Output the tables published in the user guide."),
@@ -9690,7 +9820,7 @@ def cache_model_cmd(
9690
9820
  typer.echo(_cmodel.render_text())
9691
9821
 
9692
9822
 
9693
- @cache_app.command("freshness")
9823
+ @cache_app.command("freshness", short_help="RIS freshness relative to the current git HEAD")
9694
9824
  def cache_freshness_cmd(
9695
9825
  path: Path = typer.Argument(Path("."), help="Repository path (default: current directory)"),
9696
9826
  json_output: bool = typer.Option(False, "--json", help="Output as JSON."),
@@ -9772,7 +9902,7 @@ def cache_freshness_cmd(
9772
9902
  typer.echo(f"RIS updated: {result.get('ris_last_updated_at') or 'never'}")
9773
9903
 
9774
9904
 
9775
- @cache_app.command("context-stats")
9905
+ @cache_app.command("context-stats", short_help="AI context cache: size and reuse metrics")
9776
9906
  def cache_context_stats_cmd(
9777
9907
  path: Path = typer.Argument(Path("."), help="Repository path (default: current directory)"),
9778
9908
  json_output: bool = typer.Option(False, "--json", help="Output as JSON."),
@@ -9804,7 +9934,7 @@ def cache_context_stats_cmd(
9804
9934
  typer.echo(f"KL version: {stats['kl_schema_version']} producer={stats['producer_version']}")
9805
9935
 
9806
9936
 
9807
- @cache_app.command("context-clear")
9937
+ @cache_app.command("context-clear", short_help="Delete the AI context cache for a repository")
9808
9938
  def cache_context_clear_cmd(
9809
9939
  path: Path = typer.Argument(Path("."), help="Repository path (default: current directory)"),
9810
9940
  yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt."),
@@ -9929,6 +10059,11 @@ def main_entry() -> None:
9929
10059
  routing for tokens like 'version' or 'config').
9930
10060
  """
9931
10061
  _force_utf8_streams()
10062
+ # A reader that stops reading is not a failed run (C3-28). Installed after
10063
+ # the UTF-8 reconfiguration so the wrapper keeps that stream, and before any
10064
+ # command writes a byte.
10065
+ from sourcecode.pipe_contract import install as _install_pipe_contract
10066
+ _install_pipe_contract()
9932
10067
  # Deprecation notice when invoked through the legacy `sourcecode` alias.
9933
10068
  # One line, and only on an interactive terminal — pipes/agents that still call
9934
10069
  # `sourcecode` keep clean stderr (error envelopes are JSON on stderr), so the
sourcecode/compare.py CHANGED
@@ -34,9 +34,11 @@ if TYPE_CHECKING:
34
34
  COMPARE_SCHEMA: str = "candidate-comparison-v1"
35
35
 
36
36
  # Resolutions on which D3's build_change_plan returns a bare resolution notice
37
- # (no affected set). Mirror that exact bail condition so "resolved" here means
38
- # "build_change_plan actually produced a plan for this target".
39
- _UNRESOLVED: frozenset[str] = frozenset({"not_found", "ambiguous_path"})
37
+ # (no affected set), so "resolved" here means "build_change_plan actually
38
+ # produced a plan for this target". Read from the authority rather than
39
+ # restated: this set had been written out three times, and the copy in the CLI
40
+ # is what let `plan` and `impact` disagree about the same fact (C3-29).
41
+ from sourcecode.target_admission import UNRESOLVED_RESOLUTIONS as _UNRESOLVED
40
42
 
41
43
 
42
44
  def _measured_count(blast: dict, stat_key: str, list_key: str) -> tuple[int, bool]:
sourcecode/explain.py CHANGED
@@ -206,7 +206,18 @@ def _resolve_fqn(class_name: str, cir: "CanonicalRepositoryIR") -> tuple[str, li
206
206
 
207
207
  Returns (best_fqn, all_matches).
208
208
  best_fqn is empty string when no match found.
209
+
210
+ A target that names a *file* is resolved by that file first (C3-35): the
211
+ path is the more specific input, and this resolver used to answer
212
+ `not_found` for it because no CIR symbol is ever spelled as a path.
209
213
  """
214
+ from sourcecode.target_admission import fqns_declared_in_file, is_path_like
215
+
216
+ if is_path_like(class_name):
217
+ by_file = sorted(fqns_declared_in_file(class_name, _get_raw_nodes(cir)))
218
+ if by_file:
219
+ return by_file[0], by_file
220
+
210
221
  suffix_dot = f".{class_name}"
211
222
  suffix_hash = f"{class_name}#"
212
223
  matches: list[str] = []
@@ -253,8 +253,13 @@ def trim_to_budget(
253
253
  )
254
254
  if label:
255
255
  _warn_line = f"[{label}] {_warn_line}"
256
- sys.stderr.write(_warn_line)
257
- sys.stderr.flush()
256
+ # C3-26/C3-34: the run succeeded and the payload is on stdout, so
257
+ # this line speaks to a person at a terminal. On a pipe it is noise
258
+ # that PowerShell 5.1 turns into a NativeCommandError — and the fact
259
+ # itself is already in the payload, under `_truncation_summary`.
260
+ if sys.stderr.isatty():
261
+ sys.stderr.write(_warn_line)
262
+ sys.stderr.flush()
258
263
 
259
264
  return result
260
265