sourcecode 4.11.0__py3-none-any.whl → 4.12.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.11.0"
7
+ __version__ = "4.12.0"
@@ -30,6 +30,7 @@ the perf baselines do.
30
30
  from __future__ import annotations
31
31
 
32
32
  import json
33
+ import os
33
34
  import subprocess
34
35
  from datetime import datetime, timezone
35
36
  from pathlib import Path
@@ -365,21 +366,40 @@ def _baseline_filename(baseline: dict) -> str:
365
366
  #: a 14 587-entry structural inventory of the code. A self-confined
366
367
  #: `.ask/.gitignore` is the honest form: it never touches the user's own
367
368
  #: `.gitignore`, and it stops applying the moment they delete the directory.
369
+ #: C3-74 corrected the last line. It used to read *"Delete this file to version
370
+ #: them deliberately"*, and deleting it is the one action that would commit a
371
+ #: baseline carrying `env.hostname_hash`, `cpu` and `cores` — an invitation to do
372
+ #: the thing the file exists to prevent. It now states the consequence instead.
368
373
  _ASK_DIR_GITIGNORE = (
369
374
  "# Written by `ask` the first time it created this directory.\n"
370
375
  "# These are local analysis artefacts: a structural inventory of your code\n"
371
376
  "# and, if you asked for it, the machine that measured it. Nothing here\n"
372
- "# belongs in a commit. Delete this file to version them deliberately.\n"
377
+ "# belongs in a commit.\n"
378
+ "#\n"
379
+ "# Removing this file makes everything here committable, including any\n"
380
+ "# recorded machine fingerprint (`--record-env`: hostname hash, CPU, cores).\n"
381
+ "# Version these deliberately only if you intend to publish that.\n"
373
382
  "*\n"
374
383
  )
375
384
 
376
385
 
377
- def protect_ask_dir(out_dir: Path) -> None:
386
+ def protect_ask_dir(out_dir: Path, *, announce: bool = True) -> None:
378
387
  """Give a freshly created `.ask/` its own `.gitignore` (C3-68).
379
388
 
380
389
  Best-effort and never overwriting: a user who edited it decided something,
381
390
  and a tool that re-imposes its default on every run is worse than one that
382
391
  never wrote it.
392
+
393
+ **Call this where the write happens, never where the path is computed.** This
394
+ function *creates* the directory in order to protect it, so calling it
395
+ speculatively is a write — which is how `migrate-check` came to leave a
396
+ `.ask/.gitignore` inside a repository it had only read (C3-74).
397
+
398
+ `announce` puts one line on stderr the first time the directory is created,
399
+ with its path. A side effect inside somebody else's repository is exactly what
400
+ I-8 says must be declared, and this one hides from the check an auditor makes:
401
+ `git status --porcelain` is clean because the ignore file ignores itself, and
402
+ only `--ignored` reveals `!! <scope>/.ask/`.
383
403
  """
384
404
  try:
385
405
  ask_dir = Path(out_dir)
@@ -391,12 +411,35 @@ def protect_ask_dir(out_dir: Path) -> None:
391
411
  return
392
412
  marker = ask_dir / ".gitignore"
393
413
  if not marker.exists():
414
+ created = not ask_dir.exists()
394
415
  ask_dir.mkdir(parents=True, exist_ok=True)
395
416
  marker.write_text(_ASK_DIR_GITIGNORE, encoding="utf-8")
417
+ if announce and created:
418
+ _announce_ask_dir(ask_dir)
396
419
  except OSError:
397
420
  return
398
421
 
399
422
 
423
+ def _announce_ask_dir(ask_dir: Path) -> None:
424
+ """One line, on stderr, naming what was created and how to move it (C3-74)."""
425
+ import sys
426
+
427
+ try:
428
+ # No flag is named here on purpose: the commands that write under `.ask/`
429
+ # spell the override differently (`baseline capture --dir`,
430
+ # `migrate-check --history-dir`), and a message that names the wrong one
431
+ # is the class of defect this ledger is about.
432
+ sys.stderr.write(
433
+ f"[ask] created {ask_dir}{os.sep} for this run's local artefacts — "
434
+ f"git-ignored by its own .gitignore, so `git status` will not show it "
435
+ f"(`git status --ignored` will). The command's directory option takes a "
436
+ f"path outside the repository if you want them elsewhere.\n"
437
+ )
438
+ sys.stderr.flush()
439
+ except Exception:
440
+ pass
441
+
442
+
400
443
  def write_baseline(baseline: dict, out_dir: Path) -> Path:
401
444
  """Write `baseline` as deterministic JSON under `out_dir`; return the file path."""
402
445
  out_dir.mkdir(parents=True, exist_ok=True)
sourcecode/cache_model.py CHANGED
@@ -75,10 +75,17 @@ class CommandCache:
75
75
  warm_seconds: "Optional[float]" = None
76
76
  #: The other measured point: this command on the field repository
77
77
  #: (3 342 Java files, Windows 11 / PowerShell 5.1 / pipx, cold), from field
78
- #: evaluations #13 and #14. Seconds where a run finished; `field_blocked`
78
+ #: evaluations #13, #14 and #16. Seconds where a run finished; `field_blocked`
79
79
  #: where it did not — the harness promoted the process and the session died,
80
80
  #: which is a measurement of a different kind and is never written as a
81
81
  #: duration. Rows with neither were not attempted there.
82
+ #:
83
+ #: C3-72: where two evaluations of the same command at this size disagree, the
84
+ #: figure here is the **most recent** one, because it describes the build a
85
+ #: reader is about to run — and the previous figure is kept in the comment
86
+ #: beside it rather than dropped, since a cost that moved by 5,8× between two
87
+ #: releases (spring-audit, 408 s in #13 against 2 351 s in #16) is itself the
88
+ #: measurement C3-76 exists for.
82
89
  field_seconds: "Optional[float]" = None
83
90
  field_blocked: bool = False
84
91
 
@@ -93,19 +100,26 @@ REFERENCE_REPOSITORY = (
93
100
  REFERENCE_JAVA_FILES = 2000
94
101
 
95
102
  #: The other end of the measured range, and the reason this module publishes a
96
- #: *class* rather than a projected duration (C4-19). Field evaluation #13, on
103
+ #: *class* rather than a projected duration (C4-19). Field evaluation #16, on
97
104
  #: Windows 11 / PowerShell 5.1 / pipx / Python 3.10: `spring-audit` on a
98
- #: 3 342-file repository ran **408 s** — 46× the 8.8 s the same command takes on
99
- #: the 2 000-file reference. Cost does not scale with file count in any way this
100
- #: product has measured, so a projected "~15 s on your repository" would be a
101
- #: confident falsehood of exactly the kind the ledger exists to prevent. What can
102
- #: be said honestly is the class, the two measured anchors, and how to run it.
105
+ #: 3 342-file repository ran **2 351 s** — 267× the 8.8 s the same command takes
106
+ #: on the 2 000-file reference, for a repository 1,7× the size. Cost does not
107
+ #: scale with file count in any way this product has measured, so a projected
108
+ #: "~15 s on your repository" would be a confident falsehood of exactly the kind
109
+ #: the ledger exists to prevent. What can be said honestly is the class, the two
110
+ #: measured anchors, and how to run it.
111
+ #:
112
+ #: C3-72 replaced evaluation #13's 408 s with #16's 2 351 s here: the same command
113
+ #: on the same repository at the same commit, measured three releases later
114
+ #: (2 557 s in 4.10.6, 2 398 s in 4.10.7, 2 351 s in 4.11.0). The older figure was
115
+ #: the one a budget message quoted as "~600s" for three releases while the
116
+ #: measurement stood four times higher.
103
117
  FIELD_ANCHOR = (
104
- "field evaluation #13: spring-audit on 3 342 Java files took 408 s "
105
- "(Windows, pipx, cold) against 8.8 s on the 2 000-file reference"
118
+ "field evaluation #16: spring-audit on 3 342 Java files took 2 351 s "
119
+ "(Windows, pipx, warm cache) against 8.8 s on the 2 000-file reference"
106
120
  )
107
121
  FIELD_ANCHOR_JAVA_FILES = 3342
108
- FIELD_ANCHOR_SECONDS = 408.0
122
+ FIELD_ANCHOR_SECONDS = 2351.4
109
123
 
110
124
 
111
125
  #: Every layer keys on `cache.worktree_signature` — the exact tree state (C1-9).
@@ -176,14 +190,15 @@ COMMANDS: tuple[CommandCache, ...] = (
176
190
  "Resolves the conditional bean graph on every run, over the shared CIR a warm "
177
191
  "builds — the parse it used to repeat for itself. `--diff` compares two profile "
178
192
  "sets over that one IR, so the second side costs the resolution only.",
179
- "10.1 s → 1.6 s", analysis_class="repo-wide", cold_seconds=10.1, warm_seconds=1.6, field_blocked=True),
193
+ "10.1 s → 1.6 s", analysis_class="repo-wide", cold_seconds=10.1, warm_seconds=1.6, field_seconds=38.0),
180
194
  CommandCache("risk", ("cir", "parse"), "shared", False,
181
195
  "Composes what the audit, impact-chain and the posture already answer, so it "
182
196
  "pays each of their costs once over the shared CIR a warm builds — one parse "
183
197
  "for the whole composition, and the reachability query is cached per symbol "
184
198
  "within the run.",
185
199
  "not measured on the battery yet — the composition is bounded by the "
186
- "`spring-audit` + `impact-chain` costs listed here, not by new analysis", analysis_class="deep"),
200
+ "`spring-audit` + `impact-chain` costs listed here, not by new analysis",
201
+ analysis_class="deep", field_seconds=3232.2),
187
202
  CommandCache("enrich", ("cir", "parse"), "shared", False,
188
203
  "Runs the same composition as `risk` over the repository, then joins a SARIF "
189
204
  "log to it. Reading the log is negligible; everything a warm helps with is the "
@@ -213,14 +228,14 @@ COMMANDS: tuple[CommandCache, ...] = (
213
228
  "Recomputes the endpoint surface on every run, over a parse a warm has already "
214
229
  "paid for. Until 3.7.0 the extractor parsed every file itself instead of reading "
215
230
  "the shared parse cache, and a warm measurably bought it nothing (C3-6).",
216
- "3.3 s → 1.4 s (re-measured on 3.7.0; was 2.8 s → 2.9 s)", analysis_class="repo-wide", cold_seconds=3.3, warm_seconds=1.4, field_seconds=4.2),
231
+ "3.3 s → 1.4 s (re-measured on 3.7.0; was 2.8 s → 2.9 s)", analysis_class="repo-wide", cold_seconds=3.3, warm_seconds=1.4, field_seconds=5.0),
217
232
  CommandCache("spring-audit", ("ris", "parse"), "shared", False,
218
233
  "Recomputes every run, but over a parse a warm has already paid for.",
219
- "8.8 s → 3.7 s", analysis_class="repo-wide", cold_seconds=8.8, warm_seconds=3.7, field_seconds=408.0),
234
+ "8.8 s → 3.7 s", analysis_class="repo-wide", cold_seconds=8.8, warm_seconds=3.7, field_seconds=2351.4),
220
235
  CommandCache("migrate-check", ("cir",), "none", False,
221
236
  "Computes its own inventory and shares nothing a warm builds. Only `--blast-radius` "
222
237
  "reuses the shared CIR.",
223
- "4.8 s → 4.8 s", analysis_class="repo-wide", cold_seconds=4.8, warm_seconds=4.8, field_blocked=True),
238
+ "4.8 s → 4.8 s", analysis_class="repo-wide", cold_seconds=4.8, warm_seconds=4.8, field_seconds=26.7),
224
239
  CommandCache("impact-chain", ("cir", "parse"), "shared", False,
225
240
  "The CIR is the expensive half — this is where a warm pays most.",
226
241
  "9.9 s → 1.7 s", analysis_class="core", cold_seconds=9.9, warm_seconds=1.7),
sourcecode/cli.py CHANGED
@@ -1232,7 +1232,32 @@ def _analysis_budget(command: str) -> dict[str, Any]:
1232
1232
  }
1233
1233
 
1234
1234
 
1235
- def _enforce_analysis_budget(command: str, budget: dict[str, Any]) -> None:
1235
+ def _budget_evidence(command: str, path: "Optional[Path]") -> "tuple[Optional[str], Optional[str]]":
1236
+ """(measured anchors for this command, size of this repository) — or Nones.
1237
+
1238
+ C3-72: both halves already exist and neither reached the one message an
1239
+ operator reads before deciding to wait. `execution_plan` owns the formatting
1240
+ of the anchors (the same string `--help` and the remedies print) and the
1241
+ cheap, capped scope measurement, so nothing new is computed or claimed here.
1242
+ """
1243
+ anchors: Optional[str] = None
1244
+ scope: Optional[str] = None
1245
+ try:
1246
+ from sourcecode import execution_plan as _plan
1247
+
1248
+ anchors = _plan._reference(_plan.cost_row(command))
1249
+ if path is not None:
1250
+ measured = _plan.scope_for(Path(path))
1251
+ if measured is not None and measured.measured:
1252
+ scope = measured.describe()
1253
+ except Exception:
1254
+ pass # a message about cost must never be the reason a run fails
1255
+ return anchors, scope
1256
+
1257
+
1258
+ def _enforce_analysis_budget(
1259
+ command: str, budget: dict[str, Any], path: "Optional[Path]" = None
1260
+ ) -> None:
1236
1261
  """Warn when the operator's budget is under the class floor — never refuse.
1237
1262
 
1238
1263
  C3-55: below the floor was rejected and above it admitted a run that killed
@@ -1254,11 +1279,13 @@ def _enforce_analysis_budget(command: str, budget: dict[str, Any]) -> None:
1254
1279
  return
1255
1280
  from sourcecode.phased_run import budget_warning
1256
1281
 
1282
+ _anchors, _scope = _budget_evidence(command, path)
1257
1283
  try:
1258
1284
  sys.stderr.write(
1259
1285
  budget_warning(
1260
1286
  command, str(budget.get("class") or "repo-wide"),
1261
1287
  float(configured), recommended,
1288
+ anchors=_anchors, scope=_scope,
1262
1289
  )
1263
1290
  )
1264
1291
  sys.stderr.flush()
@@ -1352,7 +1379,7 @@ def _expensive_analysis_scope(command: str, path: Path, phase: str):
1352
1379
  lock_path = _analysis_lock_path(path)
1353
1380
  _emit_analysis_contention_warning(command, path, phase)
1354
1381
  budget = _analysis_budget(command)
1355
- _enforce_analysis_budget(command, budget)
1382
+ _enforce_analysis_budget(command, budget, path)
1356
1383
  run_record = RunRecord.start(path, command, phase, budget=budget)
1357
1384
  run_token = set_active_run(run_record)
1358
1385
  token = f"{os.getpid()}:{time.time_ns()}"
@@ -1418,6 +1445,53 @@ def _work_sink(prog: Any) -> Any:
1418
1445
  return sink
1419
1446
 
1420
1447
 
1448
+ def _rule_pass_progress(
1449
+ prog: Any,
1450
+ stage: str,
1451
+ unit: str = "rule families",
1452
+ *,
1453
+ stop_when: Any = None,
1454
+ ) -> Any:
1455
+ """The rule-pass observer an auditor reports through, or None (C3-73).
1456
+
1457
+ The per-file passes are counted (C3-56) and the rule pass — which is most of a
1458
+ repository-wide audit's wall time — was not, so the field watched one
1459
+ unchanging line for 39 minutes at exactly the moment the question is *"is this
1460
+ working, or is it hung?"*. The rule families are the population this loop
1461
+ walks, so they are what it counts.
1462
+
1463
+ `estimate=False`: the families are not comparable units. One walks every symbol
1464
+ in the repository, the next reads one annotation, so a rate over the first four
1465
+ says nothing about the fifth. The count answers *"is it moving"*, which is the
1466
+ question; an ETA here would be a number rather than an estimate, and this line
1467
+ exists to be trusted.
1468
+
1469
+ `stop_when` is the operator's budget (C3-71), and it is the reason this returns
1470
+ an observer even when nothing is watching: the heartbeat is disabled off a TTY
1471
+ and in CI, which is **precisely** where a budget matters, so gating the observer
1472
+ on the spinner would have honoured the budget only on a developer's terminal.
1473
+ No heartbeat and no budget is the only case that returns None, and then the
1474
+ loops run exactly as they did before.
1475
+ """
1476
+ from sourcecode.rule_pass import RulePassProgress
1477
+
1478
+ watching = prog is not None and getattr(prog, "enabled", False)
1479
+ if not watching and stop_when is None:
1480
+ return None
1481
+
1482
+ def sink(done: int, total: int, rule_id: str) -> None:
1483
+ prog.work(
1484
+ done, total,
1485
+ stage=f"{stage} ({rule_id})",
1486
+ unit=unit,
1487
+ estimate=False,
1488
+ )
1489
+
1490
+ return RulePassProgress(
1491
+ sink=sink if watching else None, stop_when=stop_when
1492
+ )
1493
+
1494
+
1421
1495
  def _reserialize_as_yaml(content: str) -> str:
1422
1496
  """JSON document → YAML, or the content unchanged when it is not one.
1423
1497
 
@@ -7995,29 +8069,64 @@ def spring_audit_cmd(
7995
8069
  _model = SpringSemanticModel.build(cir)
7996
8070
  _run.checkpoint("ir")
7997
8071
  # The IR is built; the rule passes are the work now, and they are not
7998
- # counted per file. Saying so drops the denominator rather than
7999
- # leaving `2766/2766` frozen on the line while they run (C3-56).
8072
+ # counted per file. Saying so drops the per-file denominator rather
8073
+ # than leaving `2766/2766` frozen on the line while they run (C3-56)
8074
+ # — and C3-73 gives the pass the denominator it does have: the rule
8075
+ # families it evaluates, counted as it walks them.
8000
8076
  _prog.update(f"applying {scope} rules over {len(file_list)} Java files")
8001
8077
 
8002
8078
  results: list[SpringAuditResult] = []
8079
+ # C3-71: the budget reaches the loop that spends the time. Checking it
8080
+ # only between the three phases meant it could be observed at two
8081
+ # instants in a 39-minute run, and the phase holding ~85 % of the time
8082
+ # was never interrupted — so `ASK_MAX_ANALYSIS_SECONDS=420` produced a
8083
+ # 2 351 s run, exit 0, and no `partial` anywhere. `PhasedRun.exhausted`
8084
+ # stays the one authority on whether the time is spent; what is new is
8085
+ # that the rule pass asks it.
8086
+ _rules_cut: list[str] = []
8087
+ _phases_cut: list[str] = []
8003
8088
  if scope in ("all", "tx"):
8004
8089
  if _run.exhausted():
8005
8090
  _stopped_early = _WHY_BUDGET
8006
8091
  else:
8007
- results.append(run_tx_audit(cir, root=target, min_severity=min_severity, model=_model))
8008
- _run.checkpoint(
8009
- "tx_audit",
8010
- SpringAuditResult.merge(list(results), scope=scope).to_compact_dict(),
8092
+ _tx_watch = _rule_pass_progress(
8093
+ _prog, "transaction rules", stop_when=_run.exhausted
8011
8094
  )
8095
+ results.append(run_tx_audit(
8096
+ cir, root=target, min_severity=min_severity, model=_model,
8097
+ progress=_tx_watch,
8098
+ ))
8099
+ _cut = list(getattr(_tx_watch, "not_run", []) or [])
8100
+ _rules_cut.extend(_cut)
8101
+ if _cut:
8102
+ _stopped_early = _WHY_BUDGET
8103
+ _phases_cut.append("tx_audit")
8104
+ else:
8105
+ _run.checkpoint(
8106
+ "tx_audit",
8107
+ SpringAuditResult.merge(list(results), scope=scope).to_compact_dict(),
8108
+ )
8012
8109
  if scope in ("all", "security"):
8013
8110
  if _run.exhausted():
8014
8111
  _stopped_early = _WHY_BUDGET
8015
8112
  else:
8016
- results.append(run_security_audit(cir, root=target, min_severity=min_severity, model=_model))
8017
- _run.checkpoint(
8018
- "security_audit",
8019
- SpringAuditResult.merge(list(results), scope=scope).to_compact_dict(),
8113
+ _sec_watch = _rule_pass_progress(
8114
+ _prog, "security rules", stop_when=_run.exhausted
8020
8115
  )
8116
+ results.append(run_security_audit(
8117
+ cir, root=target, min_severity=min_severity, model=_model,
8118
+ progress=_sec_watch,
8119
+ ))
8120
+ _cut = list(getattr(_sec_watch, "not_run", []) or [])
8121
+ _rules_cut.extend(_cut)
8122
+ if _cut:
8123
+ _stopped_early = _WHY_BUDGET
8124
+ _phases_cut.append("security_audit")
8125
+ else:
8126
+ _run.checkpoint(
8127
+ "security_audit",
8128
+ SpringAuditResult.merge(list(results), scope=scope).to_compact_dict(),
8129
+ )
8021
8130
 
8022
8131
  combined = SpringAuditResult.merge(results, scope=scope)
8023
8132
 
@@ -8040,17 +8149,43 @@ def spring_audit_cmd(
8040
8149
  # The answer is a floor over the phases that ran. It says so in
8041
8150
  # the payload rather than in prose nobody parses, and in the
8042
8151
  # limitation list the reader already consults.
8043
- combined.limitations.append(
8044
- "Partial answer: the analysis budget ran out after "
8045
- f"{', '.join(_run.completed)}; "
8046
- f"{', '.join(_run.pending())} did not run. Counts are a floor "
8047
- "over the completed phases — absence of a finding from a "
8048
- "phase that never ran is not evidence there is none."
8049
- )
8152
+ if _rules_cut:
8153
+ # C3-71: the phase was stopped *inside*, so saying it "did not
8154
+ # run" would be false — four of its five families answered.
8155
+ # Name what did not run, at the granularity it was cut on.
8156
+ combined.limitations.append(
8157
+ "Partial answer: the analysis budget ran out during the "
8158
+ f"rule pass, after {', '.join(_run.completed)}. The rule "
8159
+ f"families that did not run: {', '.join(_rules_cut)}. Counts "
8160
+ "are a floor over what did run — absence of a finding from a "
8161
+ "family that never ran is not evidence there is none."
8162
+ )
8163
+ else:
8164
+ combined.limitations.append(
8165
+ "Partial answer: the analysis budget ran out after "
8166
+ f"{', '.join(_run.completed)}; "
8167
+ f"{', '.join(_run.pending())} did not run. Counts are a floor "
8168
+ "over the completed phases — absence of a finding from a "
8169
+ "phase that never ran is not evidence there is none."
8170
+ )
8050
8171
 
8051
8172
  data = combined.to_compact_dict() if compact else combined.to_dict()
8052
8173
  if _stopped_early:
8053
8174
  data["_partial"] = _run.status(_stopped_early)
8175
+ if _rules_cut:
8176
+ # C3-71: a phase stopped *inside* is neither completed nor
8177
+ # pending, and calling it either would misreport the run. The
8178
+ # families it did not reach are named instead.
8179
+ data["_partial"]["rules_not_run"] = _rules_cut
8180
+ # A phase that was stopped inside is in neither list, and
8181
+ # putting it in `phases_pending` alone would claim none of it
8182
+ # ran when most of it did.
8183
+ data["_partial"]["phases_stopped_inside"] = _phases_cut
8184
+ data["_partial"]["how_to_read"] += (
8185
+ " The rule pass was stopped between families: "
8186
+ f"{', '.join(_rules_cut)} did not run, so every count here "
8187
+ "is a floor over the families that did."
8188
+ )
8054
8189
 
8055
8190
  # Non-fatal RIS side-effect — persist summary only (not full findings).
8056
8191
  try:
@@ -8275,6 +8410,15 @@ def verify_cmd(
8275
8410
  "--ci/--no-ci",
8276
8411
  help="Exit non-zero on a failing/unverified report (default on — for pipelines).",
8277
8412
  ),
8413
+ allow_unverified: bool = typer.Option(
8414
+ False,
8415
+ "--allow-unverified",
8416
+ help=(
8417
+ "Exit 0 when nothing could be verified (no contracts declared yet, or "
8418
+ "the gate could not run). The verdict stays `unverified` in the payload "
8419
+ "— this is the declared form of `|| true`, and violations still block."
8420
+ ),
8421
+ ),
8278
8422
  copy: bool = _copy_option(),
8279
8423
  progress_mode: Optional[str] = _progress_option(),
8280
8424
  ) -> None:
@@ -8301,6 +8445,14 @@ def verify_cmd(
8301
8445
  repository could not be analysed — never a silent pass). Same codes as
8302
8446
  `verify-edit`.
8303
8447
 
8448
+ \b
8449
+ Adopting the gate (C3-75): the payload names which unverified state it is —
8450
+ `unverified_reason: no_contracts_declared` (this repository has not adopted
8451
+ contracts) or `gate_could_not_run`. A pipeline added before the contracts exist
8452
+ can pass `--allow-unverified` to exit 0 while the verdict stays `unverified`;
8453
+ violations still block. That is the declared version of `|| true`, which is what
8454
+ teams actually reach for and which then hides a real failure forever.
8455
+
8304
8456
  \b
8305
8457
  Where the repository already keeps an architectural history (`.ask/baselines`),
8306
8458
  this run adds the current commit to it and reports what it did under
@@ -8419,7 +8571,8 @@ def verify_cmd(
8419
8571
  _prog.start("verifying contracts")
8420
8572
  try:
8421
8573
  report = _vr.verify_repo(
8422
- path, fail_on=fail_on, baseline_path=baseline, on_cir=_capture
8574
+ path, fail_on=fail_on, baseline_path=baseline, on_cir=_capture,
8575
+ allow_unverified=allow_unverified,
8423
8576
  )
8424
8577
  except _vr.VerifyError as exc:
8425
8578
  _prog.stop()
@@ -8571,11 +8724,34 @@ def risk_cmd(
8571
8724
  # without a single byte on stderr while it ran.
8572
8725
  phase = "composing risk factors"
8573
8726
  risk_limit = 100000 if table and (rule_filter or band_filter or top_n) else limit
8727
+ # C3-77: 3 232 s in the field, all-or-nothing, while `spring-audit` beside it
8728
+ # already kept what it measured (C3-54 part 3). The phases are declared here so
8729
+ # a run that is killed — by an agent harness, a CI timeout, a closed laptop —
8730
+ # leaves the composition it already paid for, with `partial: true` and the
8731
+ # phase it reached. Same mechanism, same durable write (fsync + atomic rename),
8732
+ # same invariant: never a document that looks complete.
8733
+ _run = PhasedRun(
8734
+ command="risk",
8735
+ root=path,
8736
+ phases=["audit", "compose"],
8737
+ output_path=output_path,
8738
+ budget_seconds=_analysis_budget("risk").get("configured_max_seconds"),
8739
+ writer=_safe_write_file,
8740
+ )
8574
8741
  with _expensive_analysis_scope("risk", path, phase):
8575
8742
  _prog = Progress()
8576
8743
  _prog.start(phase)
8577
8744
  try:
8578
- data = build_risk(path, limit=risk_limit, min_band=min_band, profiles=_profile_set(profile))
8745
+ data = build_risk(
8746
+ path, limit=risk_limit, min_band=min_band,
8747
+ profiles=_profile_set(profile),
8748
+ # C3-73: 54 minutes with one line on stderr in the field. The
8749
+ # factory hands the composer an observer per counted stage.
8750
+ progress=lambda stage, unit="rule families": _rule_pass_progress(
8751
+ _prog, stage, unit
8752
+ ),
8753
+ checkpoint=_run.checkpoint,
8754
+ )
8579
8755
  finally:
8580
8756
  _prog.stop()
8581
8757
  if table:
@@ -8593,6 +8769,9 @@ def risk_cmd(
8593
8769
  f"{data['total_defects']} defects composed)"
8594
8770
  ),
8595
8771
  )
8772
+ # The complete answer is on disk; the checkpoint that stood in for it is not
8773
+ # needed and must not outlive it (C3-54 part 3's invariant, C3-77's reuse).
8774
+ _run.discard_checkpoint()
8596
8775
 
8597
8776
 
8598
8777
  @app.command("enrich")
@@ -9657,6 +9836,14 @@ def migrate_check_cmd(
9657
9836
  first→last movement — no improving/degrading label, and readiness_score is
9658
9837
  flagged not-comparable when the applicable dimension set changed.
9659
9838
 
9839
+ \b
9840
+ What this command writes (C3-74):
9841
+ Nothing, unless you pass --snapshot. Then, and only then, it creates
9842
+ <repo>/.ask/readiness-history (or --history-dir), announces the directory on
9843
+ stderr the first time, and protects it with its own .gitignore. Point
9844
+ --history-dir outside the repository for a read-only checkout or an audit
9845
+ where the tree must not change.
9846
+
9660
9847
  \b
9661
9848
  Java LTS/licensing inventory:
9662
9849
  The JSON report includes java_lts_inventory: Java 8/11/17/21/25 evidence
@@ -9691,12 +9878,12 @@ def migrate_check_cmd(
9691
9878
  raise typer.Exit(code=1)
9692
9879
 
9693
9880
  _history_dir = history_dir.resolve() if history_dir else (target / ".ask" / "readiness-history")
9694
- # C3-68: same protection wherever `.ask/` is created inside a work tree.
9695
- try:
9696
- from sourcecode.architectural_baseline import protect_ask_dir as _protect_ask_dir
9697
- _protect_ask_dir(_history_dir)
9698
- except Exception:
9699
- pass
9881
+ # C3-74: the protection belongs at the write, not at the argument parse.
9882
+ # `protect_ask_dir` *creates* `.ask/` so it can drop its ignore file in it, and
9883
+ # calling it here ran it on every invocation — so a read-only question left a
9884
+ # 283-byte artefact inside the audited repository, and the field bisected
9885
+ # eleven commands to find that this was the only one doing it. The write path
9886
+ # (`--snapshot`) calls it below, where something is actually being written.
9700
9887
 
9701
9888
  # --trend reads the stored series and reports movement over time; it does not scan.
9702
9889
  if trend:
@@ -12798,6 +12985,17 @@ def cache_status_cmd(
12798
12985
  _answer.say(
12799
12986
  f"{_label + ':':<13}{_store.get('entries', 0)} entries, {_mb} MB{_scope}"
12800
12987
  )
12988
+ # C3-70: one total hid three copies of the same repository, one per
12989
+ # release, for three releases. The composition is printed where the
12990
+ # total is, because that is where it was read.
12991
+ _stale_entries = _store.get("stale_entries")
12992
+ if _stale_entries:
12993
+ _stale_mb = round(int(_store.get("stale_bytes") or 0) / (1024 * 1024), 2)
12994
+ _answer.say(
12995
+ f"{'':<13}of which {_stale_entries} entries ({_stale_mb} MB) were "
12996
+ "written by another build and can never be read again — the next "
12997
+ "analysis retires them"
12998
+ )
12801
12999
  # RIS section
12802
13000
  if stats.get("ris_exists"):
12803
13001
  _stale_tag = " [STALE]" if stats.get("ris_is_stale") else ""
@@ -306,6 +306,27 @@ def warning_line(wired: list[ContainerWiring], measured_fan_in: Optional[int] =
306
306
  )
307
307
 
308
308
 
309
+ #: Why the numeric score is `null` rather than `0.0` on this population (C1-41).
310
+ #:
311
+ #: The level is floored here because a fan-in-derived band cannot describe a
312
+ #: component the framework invokes. The *number* was left on that same arithmetic
313
+ #: for two releases, so the field measured `risk_level=high` beside
314
+ #: `risk_score=0.0` on the aspect gating 76 % of a repository's handlers — the
315
+ #: worst possible value for its most critical symbol, from the same object whose
316
+ #: band said `high`. `0.0` is a measurement claim; what was not measured is `null`
317
+ #: (C1-39, I-3). One authority for the sentence, because both blast-radius
318
+ #: derivations publish it: `spring_impact` (impact-chain) and `repository_ir`
319
+ #: (impact / plan / compare).
320
+ SCORE_NULL_NOTE = (
321
+ "null, not 0: the container invokes this component, so its call-graph fan-in "
322
+ "is ~0 by construction and the weighted score derived from it is not a "
323
+ "measurement of this symbol (blind_spots=container_wired, NC-010). "
324
+ "`risk_level` is a floor established without it; rank this symbol by the "
325
+ "surfaces that do measure it — `ask endpoints`, `ask explain <symbol>`, "
326
+ "`ask posture` — not by a number the graph could not produce."
327
+ )
328
+
329
+
309
330
  def risk_floor_reason(wired: list[ContainerWiring]) -> str:
310
331
  """The `risk_reason` that replaces a fan-in verdict on this population."""
311
332
  kinds = sorted({k for w in wired for k in w.kinds})