sourcecode 4.12.0__py3-none-any.whl → 4.15.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__ = "4.12.0"
7
+ __version__ = "4.15.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:
@@ -1218,16 +1261,23 @@ def _analysis_budget(command: str) -> dict[str, Any]:
1218
1261
  command, ("repo-wide", 600, "run this command in a supervised or nightly job")
1219
1262
  )
1220
1263
  configured: Optional[float] = None
1264
+ source: Optional[str] = None
1221
1265
  raw = os.environ.get("ASK_MAX_ANALYSIS_SECONDS")
1222
1266
  if raw:
1223
1267
  try:
1224
1268
  configured = float(raw)
1225
1269
  except ValueError:
1226
1270
  configured = None
1271
+ else:
1272
+ # C3-83: a budget is inherited from a shell or a CI runner, so the
1273
+ # person reading `total_defects` is often not the person who set it.
1274
+ # Where it came from travels with it, from here to `_partial`.
1275
+ source = f"ASK_MAX_ANALYSIS_SECONDS={raw} (process environment)"
1227
1276
  return {
1228
1277
  "class": cls,
1229
1278
  "recommended_min_seconds": recommended,
1230
1279
  "configured_max_seconds": configured,
1280
+ "configured_source": source,
1231
1281
  "fallback": fallback,
1232
1282
  }
1233
1283
 
@@ -1275,9 +1325,23 @@ def _enforce_analysis_budget(
1275
1325
  """
1276
1326
  configured = budget.get("configured_max_seconds")
1277
1327
  recommended = float(budget.get("recommended_min_seconds") or 0)
1278
- if configured is None or float(configured) >= recommended:
1328
+ if configured is None:
1329
+ return
1330
+ source = budget.get("configured_source")
1331
+ from sourcecode.phased_run import budget_provenance, budget_warning
1332
+
1333
+ if float(configured) >= recommended:
1334
+ # C3-83: over the floor there is no advice to give, and there is still a
1335
+ # fact the reader of the answer needs — a limit set in an environment is
1336
+ # invisible to whoever reads `total_defects` afterwards.
1337
+ if not source:
1338
+ return
1339
+ try:
1340
+ sys.stderr.write(budget_provenance(command, float(configured), source))
1341
+ sys.stderr.flush()
1342
+ except Exception:
1343
+ pass
1279
1344
  return
1280
- from sourcecode.phased_run import budget_warning
1281
1345
 
1282
1346
  _anchors, _scope = _budget_evidence(command, path)
1283
1347
  try:
@@ -1285,7 +1349,7 @@ def _enforce_analysis_budget(
1285
1349
  budget_warning(
1286
1350
  command, str(budget.get("class") or "repo-wide"),
1287
1351
  float(configured), recommended,
1288
- anchors=_anchors, scope=_scope,
1352
+ anchors=_anchors, scope=_scope, source=source,
1289
1353
  )
1290
1354
  )
1291
1355
  sys.stderr.flush()
@@ -1487,8 +1551,15 @@ def _rule_pass_progress(
1487
1551
  estimate=False,
1488
1552
  )
1489
1553
 
1554
+ def detail_sink(done: int, total: int, label: str, detail_unit: str) -> None:
1555
+ # C3-79: inside a family the units *are* comparable to each other — files
1556
+ # are files — so the ETA is allowed here, unlike the family counter above.
1557
+ prog.work(done, total, stage=f"{stage} ({label})", unit=detail_unit)
1558
+
1490
1559
  return RulePassProgress(
1491
- sink=sink if watching else None, stop_when=stop_when
1560
+ sink=sink if watching else None,
1561
+ detail_sink=detail_sink if watching else None,
1562
+ stop_when=stop_when,
1492
1563
  )
1493
1564
 
1494
1565
 
@@ -1910,6 +1981,83 @@ def _output_option(help: "Optional[str]" = None) -> Any:
1910
1981
  COMMANDS_WITHOUT_OUTPUT: "frozenset[str]" = frozenset()
1911
1982
 
1912
1983
 
1984
+ #: The one help string for `--jobs` (F-AQ; the scope sentence is C3-84/CL-18).
1985
+ JOBS_OPTION_HELP = (
1986
+ "Worker processes used to parse files (default: CPU count − 1). "
1987
+ "`--jobs 1` parses in this process. Parallelism only warms the "
1988
+ "content-addressed parse cache — the answer is byte-identical at any value. "
1989
+ "It does NOT parallelise the rule pass, which is where a repository-wide "
1990
+ "audit spends most of its time, and on a warm cache there is nothing left "
1991
+ "to parse, so raising it buys nothing there. Env: ASK_JOBS."
1992
+ )
1993
+
1994
+
1995
+ def _jobs_option() -> Any:
1996
+ """The `--jobs/-j` option, declared once (F-AQ).
1997
+
1998
+ Four field evaluations asked for this, and the fourth measured it from the
1999
+ outside rather than inferring it: `threads=1`, `CPU/wall = 0,92…0,95`,
2000
+ sustained for 54 minutes on a 20-core machine.
2001
+
2002
+ The flag is process-global by nature — one invocation analyses one
2003
+ repository — so it is recorded in :mod:`sourcecode.parallel` rather than
2004
+ threaded through every analysis signature. It is published only on commands
2005
+ that parse a repository; `ASK_JOBS` covers the rest.
2006
+
2007
+ **C3-84 / CL-18.** The fifth evaluation read this help, sampled the process
2008
+ and corrected four rounds of its own diagnosis: parallelism exists and covers
2009
+ the phase that is not the cost. `threads=4` for the first seconds (parsing),
2010
+ then `threads=1` with CPU/wall **0,97** for the remaining ~99 % — the rule
2011
+ pass, applying families over a built IR — and on a warm cache, which is the
2012
+ normal case, there is nothing to parse at all. The old sentence was true
2013
+ about what the flag *does* and silent about what it does not, which is the
2014
+ half that decides whether setting it is worth anything. Saying so costs a
2015
+ line; letting an operator raise it and measure no change costs trust. The
2016
+ capability that makes the rule pass parallel is F-AT, and no help text is a
2017
+ substitute for it.
2018
+ """
2019
+ return typer.Option(None, "--jobs", "-j", min=1, help=JOBS_OPTION_HELP)
2020
+
2021
+
2022
+ def _apply_jobs(jobs: "Optional[int]") -> None:
2023
+ """Put a `--jobs` value in force for this process. Never raises."""
2024
+ try:
2025
+ from sourcecode import parallel
2026
+
2027
+ parallel.set_jobs(jobs)
2028
+ except Exception:
2029
+ pass
2030
+
2031
+
2032
+ #: The one help string for `--no-write` (F-AS).
2033
+ NO_WRITE_OPTION_HELP = (
2034
+ "Create nothing inside the analysed repository. A command that cannot answer "
2035
+ "without writing says so instead of writing. Shared caches outside the "
2036
+ "repository are unaffected. Env: ASK_READONLY=1."
2037
+ )
2038
+
2039
+ #: The flag itself, handled before the parser sees it — the `--detach` precedent
2040
+ #: (C3-53), for the same reason and a stronger one. A mode whose value is that an
2041
+ #: auditor can *rely* on it is worthless if it is accepted by five commands and
2042
+ #: rejected by seventy-nine: the one run that refuses the flag is the run that
2043
+ #: writes. Declaring it per command would be eighty-four copies of one decision
2044
+ #: that is not a command's to make.
2045
+ NO_WRITE_FLAG = "--no-write"
2046
+
2047
+
2048
+ def _apply_no_write(argv: "list[str]") -> "list[str]":
2049
+ """Consume `--no-write` from *argv*, putting the mode in force. Never raises."""
2050
+ if NO_WRITE_FLAG not in argv:
2051
+ return argv
2052
+ try:
2053
+ from sourcecode import readonly
2054
+
2055
+ readonly.set_readonly(True)
2056
+ except Exception:
2057
+ pass
2058
+ return [a for a in argv if a != NO_WRITE_FLAG]
2059
+
2060
+
1913
2061
  #: The one help string for `--format`, for the same reason as COPY_OPTION_HELP.
1914
2062
  FORMAT_OPTION_HELP = "Output format: json (default) or yaml."
1915
2063
 
@@ -2667,6 +2815,7 @@ def main(
2667
2815
  "--no-cache",
2668
2816
  help="Bypass the scan cache and force a fresh analysis.",
2669
2817
  ),
2818
+ jobs: Optional[int] = _jobs_option(),
2670
2819
  ) -> None:
2671
2820
  """Analyze a repository and produce structured context for AI coding agents.
2672
2821
 
@@ -2677,6 +2826,7 @@ def main(
2677
2826
  ask /path/to/repo --compact analyze specific path
2678
2827
  ask --agent agent-optimized output (full detail)
2679
2828
  """
2829
+ _apply_jobs(jobs)
2680
2830
  # First-run telemetry notice (skip for telemetry/version/config subcommands)
2681
2831
  if ctx.invoked_subcommand not in ("telemetry", "version", "config"):
2682
2832
  _maybe_show_telemetry_notice()
@@ -4557,6 +4707,7 @@ def prepare_context_cmd(
4557
4707
  ),
4558
4708
  ),
4559
4709
  progress_mode: Optional[str] = _progress_option(),
4710
+ jobs: Optional[int] = _jobs_option(),
4560
4711
  ) -> None:
4561
4712
  """Task-specific context for AI coding agents.
4562
4713
 
@@ -4586,6 +4737,7 @@ def prepare_context_cmd(
4586
4737
  JSON output includes evidence_manifest: claim IDs for context files and
4587
4738
  source-backed rows, plus command inputs, ASK version, git HEAD and freshness.
4588
4739
  """
4740
+ _apply_jobs(jobs)
4589
4741
  from sourcecode.prepare_context import TASKS, TaskContextBuilder
4590
4742
 
4591
4743
  if task_help:
@@ -5352,6 +5504,7 @@ def repo_ir_cmd(
5352
5504
  help="Compress output with gzip. Requires --output. Reduces large IR files by ~70-80%.",
5353
5505
  ),
5354
5506
  progress_mode: Optional[str] = _progress_option(),
5507
+ jobs: Optional[int] = _jobs_option(),
5355
5508
  ) -> None:
5356
5509
  """Deterministic symbol-level IR for Java repositories.
5357
5510
 
@@ -5376,6 +5529,7 @@ def repo_ir_cmd(
5376
5529
  ask repo-ir --max-nodes 200 --max-edges 500
5377
5530
  ask repo-ir --output ir.json.gz --gzip
5378
5531
  """
5532
+ _apply_jobs(jobs)
5379
5533
  import json as _json
5380
5534
 
5381
5535
  from sourcecode.repository_ir import apply_ir_size_limits, build_repo_ir, find_java_files
@@ -5868,6 +6022,7 @@ def endpoints_cmd(
5868
6022
  "(implies --client-usage).",
5869
6023
  ),
5870
6024
  progress_mode: Optional[str] = _progress_option(),
6025
+ jobs: Optional[int] = _jobs_option(),
5871
6026
  ) -> None:
5872
6027
  """Extract REST API endpoint surface from Java source files.
5873
6028
 
@@ -5899,6 +6054,7 @@ def endpoints_cmd(
5899
6054
  ask endpoints . --limit 10
5900
6055
  ask endpoints . --compact
5901
6056
  """
6057
+ _apply_jobs(jobs)
5902
6058
  _enforce_format("endpoints", format)
5903
6059
 
5904
6060
  target = _admit_path(path)
@@ -6418,6 +6574,7 @@ def export_cmd(
6418
6574
  "default because it dominates the export size (18 MB on a large repo).",
6419
6575
  ),
6420
6576
  progress_mode: Optional[str] = _progress_option(),
6577
+ jobs: Optional[int] = _jobs_option(),
6421
6578
  ) -> None:
6422
6579
  """Export structured, tool-agnostic codebase views for downstream tooling.
6423
6580
 
@@ -6446,6 +6603,7 @@ def export_cmd(
6446
6603
  The section flags compose; pass several to emit multiple sections in one
6447
6604
  document. --c4 assembles the full architecture export on its own.
6448
6605
  """
6606
+ _apply_jobs(jobs)
6449
6607
  from sourcecode.repository_ir import build_repo_ir, find_java_files
6450
6608
 
6451
6609
  _enforce_format("export", format)
@@ -6563,6 +6721,7 @@ def validation_cmd(
6563
6721
  help=_NO_CACHE_SUBCOMMAND_HELP,
6564
6722
  ),
6565
6723
  progress_mode: Optional[str] = _progress_option(),
6724
+ jobs: Optional[int] = _jobs_option(),
6566
6725
  ) -> None:
6567
6726
  """Map request-body validation per endpoint (constraints + custom validators).
6568
6727
 
@@ -6587,6 +6746,7 @@ def validation_cmd(
6587
6746
  ask validation . --path-prefix /owners
6588
6747
  ask validation . --format yaml
6589
6748
  """
6749
+ _apply_jobs(jobs)
6590
6750
  _enforce_format("validation", format)
6591
6751
 
6592
6752
  target = _admit_path(path)
@@ -7940,6 +8100,7 @@ def spring_audit_cmd(
7940
8100
  ),
7941
8101
  detach: bool = _detach_option(),
7942
8102
  progress_mode: Optional[str] = _progress_option(),
8103
+ jobs: Optional[int] = _jobs_option(),
7943
8104
  ) -> None:
7944
8105
  """Spring semantic audit: {{RULE_SUMMARY}}.
7945
8106
 
@@ -7993,6 +8154,7 @@ def spring_audit_cmd(
7993
8154
  {{NON_COVERAGE}}
7994
8155
  Silence about those is not evidence that there is nothing to find.
7995
8156
  """
8157
+ _apply_jobs(jobs)
7996
8158
  import json as _json
7997
8159
 
7998
8160
  from sourcecode.repository_ir import find_java_files
@@ -8054,6 +8216,8 @@ def spring_audit_cmd(
8054
8216
  phases=_phases,
8055
8217
  output_path=output_path,
8056
8218
  budget_seconds=_analysis_budget("spring-audit").get("configured_max_seconds"),
8219
+ # C3-83: the budget's origin travels with the budget, into `_partial`.
8220
+ budget_source=_analysis_budget("spring-audit").get("configured_source"),
8057
8221
  writer=_safe_write_file,
8058
8222
  )
8059
8223
  _stopped_early: Optional[str] = None
@@ -8085,6 +8249,8 @@ def spring_audit_cmd(
8085
8249
  # that the rule pass asks it.
8086
8250
  _rules_cut: list[str] = []
8087
8251
  _phases_cut: list[str] = []
8252
+ # C3-79: families the budget stopped *inside*, with how far each got.
8253
+ _rules_partial: list[dict] = []
8088
8254
  if scope in ("all", "tx"):
8089
8255
  if _run.exhausted():
8090
8256
  _stopped_early = _WHY_BUDGET
@@ -8098,7 +8264,11 @@ def spring_audit_cmd(
8098
8264
  ))
8099
8265
  _cut = list(getattr(_tx_watch, "not_run", []) or [])
8100
8266
  _rules_cut.extend(_cut)
8101
- if _cut:
8267
+ _partial_families = list(
8268
+ getattr(_tx_watch, "partially_run", []) or []
8269
+ )
8270
+ _rules_partial.extend(_partial_families)
8271
+ if _cut or _partial_families:
8102
8272
  _stopped_early = _WHY_BUDGET
8103
8273
  _phases_cut.append("tx_audit")
8104
8274
  else:
@@ -8119,7 +8289,11 @@ def spring_audit_cmd(
8119
8289
  ))
8120
8290
  _cut = list(getattr(_sec_watch, "not_run", []) or [])
8121
8291
  _rules_cut.extend(_cut)
8122
- if _cut:
8292
+ _partial_families = list(
8293
+ getattr(_sec_watch, "partially_run", []) or []
8294
+ )
8295
+ _rules_partial.extend(_partial_families)
8296
+ if _cut or _partial_families:
8123
8297
  _stopped_early = _WHY_BUDGET
8124
8298
  _phases_cut.append("security_audit")
8125
8299
  else:
@@ -8130,6 +8304,14 @@ def spring_audit_cmd(
8130
8304
 
8131
8305
  combined = SpringAuditResult.merge(results, scope=scope)
8132
8306
 
8307
+ # C2-31: a phase that never *started* is a cut this envelope cannot
8308
+ # learn from its own scopes — no auditor ran to record it. The run
8309
+ # knows, so it says so here, and `finalize()` applies the same ceiling
8310
+ # and the same floor marks it applies to a scope cut mid-pass.
8311
+ if _stopped_early and not combined.partial:
8312
+ combined.partial = True
8313
+ combined.finalize()
8314
+
8133
8315
  if _file_limitations:
8134
8316
  combined.limitations.extend(_file_limitations)
8135
8317
 
@@ -8172,6 +8354,22 @@ def spring_audit_cmd(
8172
8354
  data = combined.to_compact_dict() if compact else combined.to_dict()
8173
8355
  if _stopped_early:
8174
8356
  data["_partial"] = _run.status(_stopped_early)
8357
+ if _rules_partial:
8358
+ # C3-79: neither run nor not-run. How far it got is what a
8359
+ # reader needs to decide whether to re-run with more time, and
8360
+ # the findings it produced before the cut are in the payload.
8361
+ data["_partial"]["rules_partially_run"] = _rules_partial
8362
+ data["_partial"]["how_to_read"] += (
8363
+ " One rule family was stopped part-way through its own "
8364
+ "population: "
8365
+ + "; ".join(
8366
+ f"{'/'.join(entry['rule_ids'])} after "
8367
+ f"{entry['units_done']} of {entry['units_total']} "
8368
+ f"{entry['unit']}"
8369
+ for entry in _rules_partial
8370
+ )
8371
+ + ". What it found before the cut is reported and is a floor."
8372
+ )
8175
8373
  if _rules_cut:
8176
8374
  # C3-71: a phase stopped *inside* is neither completed nor
8177
8375
  # pending, and calling it either would misreport the run. The
@@ -8652,6 +8850,7 @@ def risk_cmd(
8652
8850
  copy: bool = _copy_option(),
8653
8851
  detach: bool = _detach_option(),
8654
8852
  progress_mode: Optional[str] = _progress_option(),
8853
+ jobs: Optional[int] = _jobs_option(),
8655
8854
  ) -> None:
8656
8855
  """[EXPERIMENTAL] What each defect actually costs, once reach and access are in it.
8657
8856
 
@@ -8697,6 +8896,7 @@ def risk_cmd(
8697
8896
  ask risk . --table --rule SEC-008 --band high --top-n 20
8698
8897
  ask risk . --limit 10 -o risk.json
8699
8898
  """
8899
+ _apply_jobs(jobs)
8700
8900
  from sourcecode.risk import build_risk
8701
8901
 
8702
8902
  path = _admit_path(path)
@@ -8736,6 +8936,8 @@ def risk_cmd(
8736
8936
  phases=["audit", "compose"],
8737
8937
  output_path=output_path,
8738
8938
  budget_seconds=_analysis_budget("risk").get("configured_max_seconds"),
8939
+ # C3-83: the budget's origin travels with the budget, into `_partial`.
8940
+ budget_source=_analysis_budget("risk").get("configured_source"),
8739
8941
  writer=_safe_write_file,
8740
8942
  )
8741
8943
  with _expensive_analysis_scope("risk", path, phase):
@@ -9373,6 +9575,7 @@ def posture_cmd(
9373
9575
  format: str = _format_option(help="Output format: json or yaml."),
9374
9576
  copy: bool = _copy_option(),
9375
9577
  progress_mode: Optional[str] = _progress_option(),
9578
+ jobs: Optional[int] = _jobs_option(),
9376
9579
  ) -> None:
9377
9580
  """Which beans are wired under a profile set — and which cannot be decided.
9378
9581
 
@@ -9410,6 +9613,7 @@ def posture_cmd(
9410
9613
  ask posture . --resolve-environments
9411
9614
  ask posture . --resolve-environments --env-file prod.env
9412
9615
  """
9616
+ _apply_jobs(jobs)
9413
9617
  from sourcecode.posture import (
9414
9618
  FAIL_ON_CHOICES,
9415
9619
  build_posture,
@@ -9782,6 +9986,7 @@ def migrate_check_cmd(
9782
9986
  ),
9783
9987
  detach: bool = _detach_option(),
9784
9988
  progress_mode: Optional[str] = _progress_option(),
9989
+ jobs: Optional[int] = _jobs_option(),
9785
9990
  ) -> None:
9786
9991
  """Spring Boot 2→3 migration readiness + Java LTS/licensing inventory.
9787
9992
 
@@ -9851,6 +10056,7 @@ def migrate_check_cmd(
9851
10056
  blockers; and explicit Oracle JDK/licensing-review signals. This is
9852
10057
  inventory evidence, not legal advice, and it never changes readiness_score.
9853
10058
  """
10059
+ _apply_jobs(jobs)
9854
10060
  from sourcecode.repository_ir import find_java_files
9855
10061
  from sourcecode.migrate_check import run_migrate_check
9856
10062
 
@@ -10106,6 +10312,7 @@ def impact_chain_cmd(
10106
10312
  ),
10107
10313
  ),
10108
10314
  progress_mode: Optional[str] = _progress_option(),
10315
+ jobs: Optional[int] = _jobs_option(),
10109
10316
  ) -> None:
10110
10317
  """Spring impact-chain: systemic blast radius of a symbol; TX/SEC findings are opt-in.
10111
10318
 
@@ -10139,6 +10346,7 @@ def impact_chain_cmd(
10139
10346
  ask impact-chain com.example.OrderService#placeOrder /path/to/repo
10140
10347
  ask impact-chain PaymentService . --depth 6 --output impact.json
10141
10348
  """
10349
+ _apply_jobs(jobs)
10142
10350
  import json as _json
10143
10351
 
10144
10352
  from sourcecode.repository_ir import find_java_files
@@ -12996,6 +13204,18 @@ def cache_status_cmd(
12996
13204
  "written by another build and can never be read again — the next "
12997
13205
  "analysis retires them"
12998
13206
  )
13207
+ # F-AR: a store in somebody else's home directory says what bounds it.
13208
+ if "budget_bytes" in _store:
13209
+ _budget_mb = _store.get("budget_mb")
13210
+ _env = _store.get("budget_env")
13211
+ if _store.get("budget_bytes"):
13212
+ _over = " — OVER, the next write evicts" if _store.get("over_budget") else ""
13213
+ _answer.say(
13214
+ f"{'':<13}budget {_budget_mb} MB, {_store.get('policy')} "
13215
+ f"(${_env}){_over}"
13216
+ )
13217
+ else:
13218
+ _answer.say(f"{'':<13}{_store.get('policy')} (${_env})")
12999
13219
  # RIS section
13000
13220
  if stats.get("ris_exists"):
13001
13221
  _stale_tag = " [STALE]" if stats.get("ris_is_stale") else ""
@@ -13604,6 +13824,24 @@ def main_entry() -> None:
13604
13824
  try:
13605
13825
  # prog_name pins usage/help to the canonical `ask`, whatever the alias.
13606
13826
  app(prog_name="ask")
13827
+ except WriteRefused as _refused:
13828
+ # F-AS. "A command that cannot answer without writing says so instead of
13829
+ # writing" — and *says so* means one sentence in the error envelope every
13830
+ # other refusal uses, never a traceback. The write did not happen; that is
13831
+ # the guarantee holding, not a fault.
13832
+ _emit_error_json(
13833
+ INVALID_INPUT_CODE,
13834
+ str(_refused),
13835
+ hint=(
13836
+ "The run was asked to create something inside the repository while "
13837
+ "--no-write (ASK_READONLY=1) was in force. Drop the flag that "
13838
+ "persists — or point its directory option outside the repository — "
13839
+ "and the same answer is produced with the artefact."
13840
+ ),
13841
+ expected="A run that creates nothing inside the analysed repository.",
13842
+ path=_refused.path,
13843
+ )
13844
+ raise SystemExit(1)
13607
13845
  finally:
13608
13846
  # Best-effort "new version available" nudge. Only speaks on an
13609
13847
  # interactive terminal; never blocks, raises, or affects exit status.