sourcecode 4.12.0__py3-none-any.whl → 4.14.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__ = "4.12.0"
7
+ __version__ = "4.14.0"
@@ -37,6 +37,7 @@ from pathlib import Path
37
37
  from typing import TYPE_CHECKING, Any
38
38
 
39
39
  from sourcecode import __version__ as TOOL_VERSION
40
+ from sourcecode import readonly as _readonly
40
41
  from sourcecode.architectural_delta import ArchMetrics, diff_metrics, extract_metrics
41
42
  from sourcecode.perf import collect_env
42
43
 
@@ -412,6 +413,9 @@ def protect_ask_dir(out_dir: Path, *, announce: bool = True) -> None:
412
413
  marker = ask_dir / ".gitignore"
413
414
  if not marker.exists():
414
415
  created = not ask_dir.exists()
416
+ # F-AS: the guard raises an OSError, which the `except OSError` below
417
+ # turns into "did not write" — the mode's promise, no new branch.
418
+ _readonly.guard(ask_dir, what="this tool's own directory")
415
419
  ask_dir.mkdir(parents=True, exist_ok=True)
416
420
  marker.write_text(_ASK_DIR_GITIGNORE, encoding="utf-8")
417
421
  if announce and created:
@@ -442,6 +446,7 @@ def _announce_ask_dir(ask_dir: Path) -> None:
442
446
 
443
447
  def write_baseline(baseline: dict, out_dir: Path) -> Path:
444
448
  """Write `baseline` as deterministic JSON under `out_dir`; return the file path."""
449
+ _readonly.guard(out_dir, what="baseline history directory")
445
450
  out_dir.mkdir(parents=True, exist_ok=True)
446
451
  protect_ask_dir(out_dir)
447
452
  path = out_dir / _baseline_filename(baseline)
sourcecode/cache_model.py CHANGED
@@ -99,6 +99,15 @@ REFERENCE_REPOSITORY = (
99
99
  #: figures are relative to.
100
100
  REFERENCE_JAVA_FILES = 2000
101
101
 
102
+ #: The build the reference figures were measured on. F-AP's second rider: C3-60
103
+ #: conditioned these figures on the repository in hand; this is the other half —
104
+ #: **keeping them current**, or, when they are not current, saying so. A table
105
+ #: measured nine releases ago and printed without its provenance is a prediction
106
+ #: presented as a measurement, which is the one thing this product does not do.
107
+ #: The nightly gate (`docs/perf/REGRESSION-GATE.md`) is what re-measures them; this
108
+ #: constant is what makes the gap visible until it does.
109
+ REFERENCE_MEASURED_VERSION = "3.2.2"
110
+
102
111
  #: The other end of the measured range, and the reason this module publishes a
103
112
  #: *class* rather than a projected duration (C4-19). Field evaluation #16, on
104
113
  #: Windows 11 / PowerShell 5.1 / pipx / Python 3.10: `spring-audit` on a
@@ -164,7 +173,9 @@ LAYERS: tuple[Layer, ...] = (
164
173
  location="~/.sourcecode/parse-cache-v1/",
165
174
  invalidated_by=(
166
175
  "the file's own bytes and the extractor's source — content-addressed, "
167
- "so it is never stale and never needs invalidating"
176
+ "so it is never stale and never needs invalidating. Bounded by "
177
+ "`ASK_PARSE_CACHE_MAX_MB` (default 512 MB, least-recently-used, "
178
+ "enforced on write) and reported in `cache status` (F-AR)"
168
179
  ),
169
180
  warmed="filled for every Java file the warm parses",
170
181
  ),
@@ -358,6 +369,34 @@ class Conditioning:
358
369
  )
359
370
 
360
371
 
372
+ def reference_currency() -> dict:
373
+ """How old the reference figures are, in releases the reader can name (F-AP).
374
+
375
+ Returns the build the figures were measured on, the build running now, and
376
+ whether they are the same. The gap is not converted into a count of releases:
377
+ versions are not a distance and this module does not invent one. Naming both
378
+ builds is enough for a reader to see that a figure was measured on a different
379
+ one — which is the whole defect: *"no predice nada a 3 342 ficheros"* applies
380
+ to the size axis, and this is the same failure on the time axis.
381
+ """
382
+ from sourcecode import __version__
383
+
384
+ current = __version__
385
+ return {
386
+ "measured_on_version": REFERENCE_MEASURED_VERSION,
387
+ "running_version": current,
388
+ "current": REFERENCE_MEASURED_VERSION == current,
389
+ "statement": (
390
+ f"Measured on {REFERENCE_MEASURED_VERSION}; you are running {current}. "
391
+ f"The figures are that build's on that repository — they are not a "
392
+ f"prediction for this one, and the nightly performance gate "
393
+ f"(docs/perf/REGRESSION-GATE.md) is what re-measures them."
394
+ if REFERENCE_MEASURED_VERSION != current
395
+ else f"Measured on {current}, the build you are running."
396
+ ),
397
+ }
398
+
399
+
361
400
  def as_dict(here: "Optional[Conditioning]" = None) -> dict:
362
401
  """The whole model as data — what `ask cache model --json` emits."""
363
402
  conditioned: dict = {}
@@ -386,6 +425,9 @@ def as_dict(here: "Optional[Conditioning]" = None) -> dict:
386
425
  ],
387
426
  "reference_repository": REFERENCE_REPOSITORY,
388
427
  "reference_java_files": REFERENCE_JAVA_FILES,
428
+ # F-AP rider: the figures carry the build they were measured on, so a
429
+ # table nine releases old cannot read as this build's measurement.
430
+ "reference_currency": reference_currency(),
389
431
  # Published beside the reference so no consumer reads the figures as a
390
432
  # law: the same command has been measured 46× slower on a repository 1.7×
391
433
  # the size, on a different platform (C4-19).
@@ -427,6 +469,15 @@ def render_markdown() -> str:
427
469
  out.append("")
428
470
  out.append(f"Measured on {REFERENCE_REPOSITORY}, each command in isolation.")
429
471
  out.append("")
472
+ # The build, not the gap: this block is a checked-in document, so naming the
473
+ # *running* version here would make the doc drift on every release. `cache
474
+ # model` names both, because there a reader has a running build to compare to.
475
+ out.append(
476
+ f"Measured on release {REFERENCE_MEASURED_VERSION}. Figures are that "
477
+ f"build's on that repository, not a prediction for yours; the nightly "
478
+ f"performance gate (`docs/perf/REGRESSION-GATE.md`) is what re-measures them."
479
+ )
480
+ out.append("")
430
481
  out.append("| Command | A warm gives it | Measured (nothing cached → after a warm) | Repeat run cached | Layers | Notes |")
431
482
  out.append("|---|---|---|---|---|---|")
432
483
  for cmd in COMMANDS:
@@ -504,6 +555,7 @@ def render_text(here: "Optional[Conditioning]" = None) -> str:
504
555
  lines.append("")
505
556
  lines.append("What a warm gives each command")
506
557
  lines.append(f" Timings: {REFERENCE_REPOSITORY}, each command measured in isolation.")
558
+ lines.append(f" {reference_currency()['statement']}")
507
559
  width = max(len(c.command) for c in COMMANDS)
508
560
  for cmd in COMMANDS:
509
561
  repeat = "repeat cached" if cmd.repeat else "recomputes"
sourcecode/cli.py CHANGED
@@ -18,6 +18,7 @@ from sourcecode import __version__
18
18
  from sourcecode.error_schema import EXECUTION_FAILED_CODE, INVALID_INPUT_CODE, build_error_envelope
19
19
  from sourcecode.entrypoint_classifier import is_production_entry_point, normalize_entry_point
20
20
  from sourcecode.progress import Progress
21
+ from sourcecode.readonly import WriteRefused
21
22
  from sourcecode.progress import output_emitted as _progress_output_emitted
22
23
  from sourcecode.output_encoding import json_ensure_ascii as _json_ensure_ascii
23
24
  from sourcecode.output_encoding import set_ascii_fallback
@@ -209,6 +210,37 @@ COMMAND_TIERS: "tuple[tuple[str, str, tuple[str, ...]], ...]" = (
209
210
  )),
210
211
  )
211
212
 
213
+ #: What each command creates inside the repository it is pointed at, and when.
214
+ #: The authority for the `--help` block below, so "which commands write" is a
215
+ #: fact with one home rather than a paragraph someone remembers to update — the
216
+ #: field asked for this by name, beside `--no-write` itself.
217
+ #:
218
+ #: A command absent from this table writes nothing inside the scope. That is a
219
+ #: claim the battery checks, not a hope: `tests/test_no_write_mode.py` fails when
220
+ #: a write seam is added without a row here.
221
+ COMMANDS_THAT_WRITE: "tuple[tuple[str, str], ...]" = (
222
+ ("baseline", "`<repo>/.ask/baselines/` (or `--dir`) — one JSON per capture"),
223
+ ("migrate-check", "`<repo>/.ask/readiness-history/` — only with `--history`"),
224
+ ("verify", "`<repo>/.ask/contracts-baseline.json` — only with `--update-baseline`"),
225
+ ("verify-edit", "the repository's git hooks directory — only with `--install-hook`"),
226
+ ("*", "`<repo>/.ask/runs/` — only when `ASK_RUNS_IN_REPO=1` is set"),
227
+ )
228
+
229
+
230
+ def _writes_help_block() -> str:
231
+ """The `--no-write` table as `--help` prints it — generated, never typed."""
232
+ lines = [
233
+ "Commands that create files inside the repository (everything else reads only):",
234
+ "",
235
+ ]
236
+ width = max(len(name) for name, _ in COMMANDS_THAT_WRITE)
237
+ for name, what in COMMANDS_THAT_WRITE:
238
+ lines.append(f" {name.ljust(width)} {what}")
239
+ lines.append("")
240
+ lines.append(" --no-write (ASK_READONLY=1) refuses all of it and says so on stderr.")
241
+ return "\n".join(lines)
242
+
243
+
212
244
  #: Tiers whose membership is short enough to name in `--help`. `supported` is the
213
245
  #: remainder by construction, and printing twenty-six names would bury the two lists
214
246
  #: a reader acts on.
@@ -473,6 +505,8 @@ ASK_MAX_ANALYSIS_SECONDS/ASK_PROGRESS when CI needs an explicit budget.
473
505
 
474
506
  {_group_help_block()}
475
507
 
508
+ {_writes_help_block()}
509
+
476
510
  [bold]Examples:[/bold]
477
511
  ask posture . --diff default:prod -o posture.json
478
512
  ask endpoints . -f json | jq '.endpoints | map(select(.method=="POST"))'
@@ -712,7 +746,7 @@ def _preprocess_args(args: list[str]) -> list[str]:
712
746
 
713
747
  def _preprocess_argv() -> None:
714
748
  """Apply _preprocess_args to sys.argv in-place (used by main_entry)."""
715
- modified = _preprocess_args(sys.argv[1:])
749
+ modified = _preprocess_args(_apply_no_write(sys.argv[1:]))
716
750
  sys.argv = sys.argv[:1] + modified
717
751
 
718
752
 
@@ -958,6 +992,15 @@ def _admit_path(
958
992
 
959
993
  admission = admit_repo_path(raw)
960
994
  if admission.admitted:
995
+ # F-AS: the one gate every command's target passes through is also the
996
+ # one place that knows which tree the no-write guarantee is about. Declared
997
+ # here rather than in each command, so a command cannot forget to.
998
+ try:
999
+ from sourcecode import readonly
1000
+
1001
+ readonly.declare_scope(admission.resolved)
1002
+ except Exception:
1003
+ pass
961
1004
  return admission.resolved
962
1005
  hint = rejection_hint(admission, usage=usage)
963
1006
  if extra_hint:
@@ -1487,8 +1530,15 @@ def _rule_pass_progress(
1487
1530
  estimate=False,
1488
1531
  )
1489
1532
 
1533
+ def detail_sink(done: int, total: int, label: str, detail_unit: str) -> None:
1534
+ # C3-79: inside a family the units *are* comparable to each other — files
1535
+ # are files — so the ETA is allowed here, unlike the family counter above.
1536
+ prog.work(done, total, stage=f"{stage} ({label})", unit=detail_unit)
1537
+
1490
1538
  return RulePassProgress(
1491
- sink=sink if watching else None, stop_when=stop_when
1539
+ sink=sink if watching else None,
1540
+ detail_sink=detail_sink if watching else None,
1541
+ stop_when=stop_when,
1492
1542
  )
1493
1543
 
1494
1544
 
@@ -1910,6 +1960,69 @@ def _output_option(help: "Optional[str]" = None) -> Any:
1910
1960
  COMMANDS_WITHOUT_OUTPUT: "frozenset[str]" = frozenset()
1911
1961
 
1912
1962
 
1963
+ #: The one help string for `--jobs` (F-AQ).
1964
+ JOBS_OPTION_HELP = (
1965
+ "Worker processes used to parse files (default: CPU count − 1). "
1966
+ "`--jobs 1` parses in this process. Parallelism only warms the "
1967
+ "content-addressed parse cache — the answer is byte-identical at any value. "
1968
+ "Env: ASK_JOBS."
1969
+ )
1970
+
1971
+
1972
+ def _jobs_option() -> Any:
1973
+ """The `--jobs/-j` option, declared once (F-AQ).
1974
+
1975
+ Four field evaluations asked for this, and the fourth measured it from the
1976
+ outside rather than inferring it: `threads=1`, `CPU/wall = 0,92…0,95`,
1977
+ sustained for 54 minutes on a 20-core machine.
1978
+
1979
+ The flag is process-global by nature — one invocation analyses one
1980
+ repository — so it is recorded in :mod:`sourcecode.parallel` rather than
1981
+ threaded through every analysis signature. It is published only on commands
1982
+ that parse a repository; `ASK_JOBS` covers the rest.
1983
+ """
1984
+ return typer.Option(None, "--jobs", "-j", min=1, help=JOBS_OPTION_HELP)
1985
+
1986
+
1987
+ def _apply_jobs(jobs: "Optional[int]") -> None:
1988
+ """Put a `--jobs` value in force for this process. Never raises."""
1989
+ try:
1990
+ from sourcecode import parallel
1991
+
1992
+ parallel.set_jobs(jobs)
1993
+ except Exception:
1994
+ pass
1995
+
1996
+
1997
+ #: The one help string for `--no-write` (F-AS).
1998
+ NO_WRITE_OPTION_HELP = (
1999
+ "Create nothing inside the analysed repository. A command that cannot answer "
2000
+ "without writing says so instead of writing. Shared caches outside the "
2001
+ "repository are unaffected. Env: ASK_READONLY=1."
2002
+ )
2003
+
2004
+ #: The flag itself, handled before the parser sees it — the `--detach` precedent
2005
+ #: (C3-53), for the same reason and a stronger one. A mode whose value is that an
2006
+ #: auditor can *rely* on it is worthless if it is accepted by five commands and
2007
+ #: rejected by seventy-nine: the one run that refuses the flag is the run that
2008
+ #: writes. Declaring it per command would be eighty-four copies of one decision
2009
+ #: that is not a command's to make.
2010
+ NO_WRITE_FLAG = "--no-write"
2011
+
2012
+
2013
+ def _apply_no_write(argv: "list[str]") -> "list[str]":
2014
+ """Consume `--no-write` from *argv*, putting the mode in force. Never raises."""
2015
+ if NO_WRITE_FLAG not in argv:
2016
+ return argv
2017
+ try:
2018
+ from sourcecode import readonly
2019
+
2020
+ readonly.set_readonly(True)
2021
+ except Exception:
2022
+ pass
2023
+ return [a for a in argv if a != NO_WRITE_FLAG]
2024
+
2025
+
1913
2026
  #: The one help string for `--format`, for the same reason as COPY_OPTION_HELP.
1914
2027
  FORMAT_OPTION_HELP = "Output format: json (default) or yaml."
1915
2028
 
@@ -2667,6 +2780,7 @@ def main(
2667
2780
  "--no-cache",
2668
2781
  help="Bypass the scan cache and force a fresh analysis.",
2669
2782
  ),
2783
+ jobs: Optional[int] = _jobs_option(),
2670
2784
  ) -> None:
2671
2785
  """Analyze a repository and produce structured context for AI coding agents.
2672
2786
 
@@ -2677,6 +2791,7 @@ def main(
2677
2791
  ask /path/to/repo --compact analyze specific path
2678
2792
  ask --agent agent-optimized output (full detail)
2679
2793
  """
2794
+ _apply_jobs(jobs)
2680
2795
  # First-run telemetry notice (skip for telemetry/version/config subcommands)
2681
2796
  if ctx.invoked_subcommand not in ("telemetry", "version", "config"):
2682
2797
  _maybe_show_telemetry_notice()
@@ -4557,6 +4672,7 @@ def prepare_context_cmd(
4557
4672
  ),
4558
4673
  ),
4559
4674
  progress_mode: Optional[str] = _progress_option(),
4675
+ jobs: Optional[int] = _jobs_option(),
4560
4676
  ) -> None:
4561
4677
  """Task-specific context for AI coding agents.
4562
4678
 
@@ -4586,6 +4702,7 @@ def prepare_context_cmd(
4586
4702
  JSON output includes evidence_manifest: claim IDs for context files and
4587
4703
  source-backed rows, plus command inputs, ASK version, git HEAD and freshness.
4588
4704
  """
4705
+ _apply_jobs(jobs)
4589
4706
  from sourcecode.prepare_context import TASKS, TaskContextBuilder
4590
4707
 
4591
4708
  if task_help:
@@ -5352,6 +5469,7 @@ def repo_ir_cmd(
5352
5469
  help="Compress output with gzip. Requires --output. Reduces large IR files by ~70-80%.",
5353
5470
  ),
5354
5471
  progress_mode: Optional[str] = _progress_option(),
5472
+ jobs: Optional[int] = _jobs_option(),
5355
5473
  ) -> None:
5356
5474
  """Deterministic symbol-level IR for Java repositories.
5357
5475
 
@@ -5376,6 +5494,7 @@ def repo_ir_cmd(
5376
5494
  ask repo-ir --max-nodes 200 --max-edges 500
5377
5495
  ask repo-ir --output ir.json.gz --gzip
5378
5496
  """
5497
+ _apply_jobs(jobs)
5379
5498
  import json as _json
5380
5499
 
5381
5500
  from sourcecode.repository_ir import apply_ir_size_limits, build_repo_ir, find_java_files
@@ -5868,6 +5987,7 @@ def endpoints_cmd(
5868
5987
  "(implies --client-usage).",
5869
5988
  ),
5870
5989
  progress_mode: Optional[str] = _progress_option(),
5990
+ jobs: Optional[int] = _jobs_option(),
5871
5991
  ) -> None:
5872
5992
  """Extract REST API endpoint surface from Java source files.
5873
5993
 
@@ -5899,6 +6019,7 @@ def endpoints_cmd(
5899
6019
  ask endpoints . --limit 10
5900
6020
  ask endpoints . --compact
5901
6021
  """
6022
+ _apply_jobs(jobs)
5902
6023
  _enforce_format("endpoints", format)
5903
6024
 
5904
6025
  target = _admit_path(path)
@@ -6418,6 +6539,7 @@ def export_cmd(
6418
6539
  "default because it dominates the export size (18 MB on a large repo).",
6419
6540
  ),
6420
6541
  progress_mode: Optional[str] = _progress_option(),
6542
+ jobs: Optional[int] = _jobs_option(),
6421
6543
  ) -> None:
6422
6544
  """Export structured, tool-agnostic codebase views for downstream tooling.
6423
6545
 
@@ -6446,6 +6568,7 @@ def export_cmd(
6446
6568
  The section flags compose; pass several to emit multiple sections in one
6447
6569
  document. --c4 assembles the full architecture export on its own.
6448
6570
  """
6571
+ _apply_jobs(jobs)
6449
6572
  from sourcecode.repository_ir import build_repo_ir, find_java_files
6450
6573
 
6451
6574
  _enforce_format("export", format)
@@ -6563,6 +6686,7 @@ def validation_cmd(
6563
6686
  help=_NO_CACHE_SUBCOMMAND_HELP,
6564
6687
  ),
6565
6688
  progress_mode: Optional[str] = _progress_option(),
6689
+ jobs: Optional[int] = _jobs_option(),
6566
6690
  ) -> None:
6567
6691
  """Map request-body validation per endpoint (constraints + custom validators).
6568
6692
 
@@ -6587,6 +6711,7 @@ def validation_cmd(
6587
6711
  ask validation . --path-prefix /owners
6588
6712
  ask validation . --format yaml
6589
6713
  """
6714
+ _apply_jobs(jobs)
6590
6715
  _enforce_format("validation", format)
6591
6716
 
6592
6717
  target = _admit_path(path)
@@ -7940,6 +8065,7 @@ def spring_audit_cmd(
7940
8065
  ),
7941
8066
  detach: bool = _detach_option(),
7942
8067
  progress_mode: Optional[str] = _progress_option(),
8068
+ jobs: Optional[int] = _jobs_option(),
7943
8069
  ) -> None:
7944
8070
  """Spring semantic audit: {{RULE_SUMMARY}}.
7945
8071
 
@@ -7993,6 +8119,7 @@ def spring_audit_cmd(
7993
8119
  {{NON_COVERAGE}}
7994
8120
  Silence about those is not evidence that there is nothing to find.
7995
8121
  """
8122
+ _apply_jobs(jobs)
7996
8123
  import json as _json
7997
8124
 
7998
8125
  from sourcecode.repository_ir import find_java_files
@@ -8085,6 +8212,8 @@ def spring_audit_cmd(
8085
8212
  # that the rule pass asks it.
8086
8213
  _rules_cut: list[str] = []
8087
8214
  _phases_cut: list[str] = []
8215
+ # C3-79: families the budget stopped *inside*, with how far each got.
8216
+ _rules_partial: list[dict] = []
8088
8217
  if scope in ("all", "tx"):
8089
8218
  if _run.exhausted():
8090
8219
  _stopped_early = _WHY_BUDGET
@@ -8098,7 +8227,11 @@ def spring_audit_cmd(
8098
8227
  ))
8099
8228
  _cut = list(getattr(_tx_watch, "not_run", []) or [])
8100
8229
  _rules_cut.extend(_cut)
8101
- if _cut:
8230
+ _partial_families = list(
8231
+ getattr(_tx_watch, "partially_run", []) or []
8232
+ )
8233
+ _rules_partial.extend(_partial_families)
8234
+ if _cut or _partial_families:
8102
8235
  _stopped_early = _WHY_BUDGET
8103
8236
  _phases_cut.append("tx_audit")
8104
8237
  else:
@@ -8119,7 +8252,11 @@ def spring_audit_cmd(
8119
8252
  ))
8120
8253
  _cut = list(getattr(_sec_watch, "not_run", []) or [])
8121
8254
  _rules_cut.extend(_cut)
8122
- if _cut:
8255
+ _partial_families = list(
8256
+ getattr(_sec_watch, "partially_run", []) or []
8257
+ )
8258
+ _rules_partial.extend(_partial_families)
8259
+ if _cut or _partial_families:
8123
8260
  _stopped_early = _WHY_BUDGET
8124
8261
  _phases_cut.append("security_audit")
8125
8262
  else:
@@ -8130,6 +8267,14 @@ def spring_audit_cmd(
8130
8267
 
8131
8268
  combined = SpringAuditResult.merge(results, scope=scope)
8132
8269
 
8270
+ # C2-31: a phase that never *started* is a cut this envelope cannot
8271
+ # learn from its own scopes — no auditor ran to record it. The run
8272
+ # knows, so it says so here, and `finalize()` applies the same ceiling
8273
+ # and the same floor marks it applies to a scope cut mid-pass.
8274
+ if _stopped_early and not combined.partial:
8275
+ combined.partial = True
8276
+ combined.finalize()
8277
+
8133
8278
  if _file_limitations:
8134
8279
  combined.limitations.extend(_file_limitations)
8135
8280
 
@@ -8172,6 +8317,22 @@ def spring_audit_cmd(
8172
8317
  data = combined.to_compact_dict() if compact else combined.to_dict()
8173
8318
  if _stopped_early:
8174
8319
  data["_partial"] = _run.status(_stopped_early)
8320
+ if _rules_partial:
8321
+ # C3-79: neither run nor not-run. How far it got is what a
8322
+ # reader needs to decide whether to re-run with more time, and
8323
+ # the findings it produced before the cut are in the payload.
8324
+ data["_partial"]["rules_partially_run"] = _rules_partial
8325
+ data["_partial"]["how_to_read"] += (
8326
+ " One rule family was stopped part-way through its own "
8327
+ "population: "
8328
+ + "; ".join(
8329
+ f"{'/'.join(entry['rule_ids'])} after "
8330
+ f"{entry['units_done']} of {entry['units_total']} "
8331
+ f"{entry['unit']}"
8332
+ for entry in _rules_partial
8333
+ )
8334
+ + ". What it found before the cut is reported and is a floor."
8335
+ )
8175
8336
  if _rules_cut:
8176
8337
  # C3-71: a phase stopped *inside* is neither completed nor
8177
8338
  # pending, and calling it either would misreport the run. The
@@ -8652,6 +8813,7 @@ def risk_cmd(
8652
8813
  copy: bool = _copy_option(),
8653
8814
  detach: bool = _detach_option(),
8654
8815
  progress_mode: Optional[str] = _progress_option(),
8816
+ jobs: Optional[int] = _jobs_option(),
8655
8817
  ) -> None:
8656
8818
  """[EXPERIMENTAL] What each defect actually costs, once reach and access are in it.
8657
8819
 
@@ -8697,6 +8859,7 @@ def risk_cmd(
8697
8859
  ask risk . --table --rule SEC-008 --band high --top-n 20
8698
8860
  ask risk . --limit 10 -o risk.json
8699
8861
  """
8862
+ _apply_jobs(jobs)
8700
8863
  from sourcecode.risk import build_risk
8701
8864
 
8702
8865
  path = _admit_path(path)
@@ -9373,6 +9536,7 @@ def posture_cmd(
9373
9536
  format: str = _format_option(help="Output format: json or yaml."),
9374
9537
  copy: bool = _copy_option(),
9375
9538
  progress_mode: Optional[str] = _progress_option(),
9539
+ jobs: Optional[int] = _jobs_option(),
9376
9540
  ) -> None:
9377
9541
  """Which beans are wired under a profile set — and which cannot be decided.
9378
9542
 
@@ -9410,6 +9574,7 @@ def posture_cmd(
9410
9574
  ask posture . --resolve-environments
9411
9575
  ask posture . --resolve-environments --env-file prod.env
9412
9576
  """
9577
+ _apply_jobs(jobs)
9413
9578
  from sourcecode.posture import (
9414
9579
  FAIL_ON_CHOICES,
9415
9580
  build_posture,
@@ -9782,6 +9947,7 @@ def migrate_check_cmd(
9782
9947
  ),
9783
9948
  detach: bool = _detach_option(),
9784
9949
  progress_mode: Optional[str] = _progress_option(),
9950
+ jobs: Optional[int] = _jobs_option(),
9785
9951
  ) -> None:
9786
9952
  """Spring Boot 2→3 migration readiness + Java LTS/licensing inventory.
9787
9953
 
@@ -9851,6 +10017,7 @@ def migrate_check_cmd(
9851
10017
  blockers; and explicit Oracle JDK/licensing-review signals. This is
9852
10018
  inventory evidence, not legal advice, and it never changes readiness_score.
9853
10019
  """
10020
+ _apply_jobs(jobs)
9854
10021
  from sourcecode.repository_ir import find_java_files
9855
10022
  from sourcecode.migrate_check import run_migrate_check
9856
10023
 
@@ -10106,6 +10273,7 @@ def impact_chain_cmd(
10106
10273
  ),
10107
10274
  ),
10108
10275
  progress_mode: Optional[str] = _progress_option(),
10276
+ jobs: Optional[int] = _jobs_option(),
10109
10277
  ) -> None:
10110
10278
  """Spring impact-chain: systemic blast radius of a symbol; TX/SEC findings are opt-in.
10111
10279
 
@@ -10139,6 +10307,7 @@ def impact_chain_cmd(
10139
10307
  ask impact-chain com.example.OrderService#placeOrder /path/to/repo
10140
10308
  ask impact-chain PaymentService . --depth 6 --output impact.json
10141
10309
  """
10310
+ _apply_jobs(jobs)
10142
10311
  import json as _json
10143
10312
 
10144
10313
  from sourcecode.repository_ir import find_java_files
@@ -12996,6 +13165,18 @@ def cache_status_cmd(
12996
13165
  "written by another build and can never be read again — the next "
12997
13166
  "analysis retires them"
12998
13167
  )
13168
+ # F-AR: a store in somebody else's home directory says what bounds it.
13169
+ if "budget_bytes" in _store:
13170
+ _budget_mb = _store.get("budget_mb")
13171
+ _env = _store.get("budget_env")
13172
+ if _store.get("budget_bytes"):
13173
+ _over = " — OVER, the next write evicts" if _store.get("over_budget") else ""
13174
+ _answer.say(
13175
+ f"{'':<13}budget {_budget_mb} MB, {_store.get('policy')} "
13176
+ f"(${_env}){_over}"
13177
+ )
13178
+ else:
13179
+ _answer.say(f"{'':<13}{_store.get('policy')} (${_env})")
12999
13180
  # RIS section
13000
13181
  if stats.get("ris_exists"):
13001
13182
  _stale_tag = " [STALE]" if stats.get("ris_is_stale") else ""
@@ -13604,6 +13785,24 @@ def main_entry() -> None:
13604
13785
  try:
13605
13786
  # prog_name pins usage/help to the canonical `ask`, whatever the alias.
13606
13787
  app(prog_name="ask")
13788
+ except WriteRefused as _refused:
13789
+ # F-AS. "A command that cannot answer without writing says so instead of
13790
+ # writing" — and *says so* means one sentence in the error envelope every
13791
+ # other refusal uses, never a traceback. The write did not happen; that is
13792
+ # the guarantee holding, not a fault.
13793
+ _emit_error_json(
13794
+ INVALID_INPUT_CODE,
13795
+ str(_refused),
13796
+ hint=(
13797
+ "The run was asked to create something inside the repository while "
13798
+ "--no-write (ASK_READONLY=1) was in force. Drop the flag that "
13799
+ "persists — or point its directory option outside the repository — "
13800
+ "and the same answer is produced with the artefact."
13801
+ ),
13802
+ expected="A run that creates nothing inside the analysed repository.",
13803
+ path=_refused.path,
13804
+ )
13805
+ raise SystemExit(1)
13607
13806
  finally:
13608
13807
  # Best-effort "new version available" nudge. Only speaks on an
13609
13808
  # interactive terminal; never blocks, raises, or affects exit status.