sourcecode 4.14.0__py3-none-any.whl → 4.16.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.14.0"
7
+ __version__ = "4.16.0"
sourcecode/cli.py CHANGED
@@ -1261,16 +1261,23 @@ def _analysis_budget(command: str) -> dict[str, Any]:
1261
1261
  command, ("repo-wide", 600, "run this command in a supervised or nightly job")
1262
1262
  )
1263
1263
  configured: Optional[float] = None
1264
+ source: Optional[str] = None
1264
1265
  raw = os.environ.get("ASK_MAX_ANALYSIS_SECONDS")
1265
1266
  if raw:
1266
1267
  try:
1267
1268
  configured = float(raw)
1268
1269
  except ValueError:
1269
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)"
1270
1276
  return {
1271
1277
  "class": cls,
1272
1278
  "recommended_min_seconds": recommended,
1273
1279
  "configured_max_seconds": configured,
1280
+ "configured_source": source,
1274
1281
  "fallback": fallback,
1275
1282
  }
1276
1283
 
@@ -1318,9 +1325,23 @@ def _enforce_analysis_budget(
1318
1325
  """
1319
1326
  configured = budget.get("configured_max_seconds")
1320
1327
  recommended = float(budget.get("recommended_min_seconds") or 0)
1321
- 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
1322
1344
  return
1323
- from sourcecode.phased_run import budget_warning
1324
1345
 
1325
1346
  _anchors, _scope = _budget_evidence(command, path)
1326
1347
  try:
@@ -1328,7 +1349,7 @@ def _enforce_analysis_budget(
1328
1349
  budget_warning(
1329
1350
  command, str(budget.get("class") or "repo-wide"),
1330
1351
  float(configured), recommended,
1331
- anchors=_anchors, scope=_scope,
1352
+ anchors=_anchors, scope=_scope, source=source,
1332
1353
  )
1333
1354
  )
1334
1355
  sys.stderr.flush()
@@ -1960,12 +1981,14 @@ def _output_option(help: "Optional[str]" = None) -> Any:
1960
1981
  COMMANDS_WITHOUT_OUTPUT: "frozenset[str]" = frozenset()
1961
1982
 
1962
1983
 
1963
- #: The one help string for `--jobs` (F-AQ).
1984
+ #: The one help string for `--jobs` (F-AQ; the scope sentence is C3-84/CL-18).
1964
1985
  JOBS_OPTION_HELP = (
1965
1986
  "Worker processes used to parse files (default: CPU count − 1). "
1966
1987
  "`--jobs 1` parses in this process. Parallelism only warms the "
1967
1988
  "content-addressed parse cache — the answer is byte-identical at any value. "
1968
- "Env: ASK_JOBS."
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."
1969
1992
  )
1970
1993
 
1971
1994
 
@@ -1980,6 +2003,18 @@ def _jobs_option() -> Any:
1980
2003
  repository — so it is recorded in :mod:`sourcecode.parallel` rather than
1981
2004
  threaded through every analysis signature. It is published only on commands
1982
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.
1983
2018
  """
1984
2019
  return typer.Option(None, "--jobs", "-j", min=1, help=JOBS_OPTION_HELP)
1985
2020
 
@@ -8181,6 +8216,8 @@ def spring_audit_cmd(
8181
8216
  phases=_phases,
8182
8217
  output_path=output_path,
8183
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"),
8184
8221
  writer=_safe_write_file,
8185
8222
  )
8186
8223
  _stopped_early: Optional[str] = None
@@ -8193,6 +8230,14 @@ def spring_audit_cmd(
8193
8230
  cir = ContextGraph.build(
8194
8231
  file_list, target, progress=_work_sink(_prog)
8195
8232
  ).cir
8233
+ # C3-85: the stretch between the last `linking` tick and the first rule
8234
+ # tick reports nothing, and what stays on the line is `n/n` — a counter
8235
+ # that finished, reading as a stage still running. Measured on keycloak
8236
+ # (5 486 files, warm): **8,2 s of a 24 s run, 34 %**, all of it here.
8237
+ # It has no population to count — it is one walk that builds the model
8238
+ # every rule then reads — so it is named, which is what C3-56 does for
8239
+ # a stage whose denominator would have to be invented.
8240
+ _prog.update(f"building the semantic model over {len(file_list)} Java files")
8196
8241
  _model = SpringSemanticModel.build(cir)
8197
8242
  _run.checkpoint("ir")
8198
8243
  # The IR is built; the rule passes are the work now, and they are not
@@ -8316,37 +8361,15 @@ def spring_audit_cmd(
8316
8361
 
8317
8362
  data = combined.to_compact_dict() if compact else combined.to_dict()
8318
8363
  if _stopped_early:
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
- )
8336
- if _rules_cut:
8337
- # C3-71: a phase stopped *inside* is neither completed nor
8338
- # pending, and calling it either would misreport the run. The
8339
- # families it did not reach are named instead.
8340
- data["_partial"]["rules_not_run"] = _rules_cut
8341
- # A phase that was stopped inside is in neither list, and
8342
- # putting it in `phases_pending` alone would claim none of it
8343
- # ran when most of it did.
8344
- data["_partial"]["phases_stopped_inside"] = _phases_cut
8345
- data["_partial"]["how_to_read"] += (
8346
- " The rule pass was stopped between families: "
8347
- f"{', '.join(_rules_cut)} did not run, so every count here "
8348
- "is a floor over the families that did."
8349
- )
8364
+ # C3-87: the block is composed by `PhasedRun.status`, which is the
8365
+ # authority `risk` is now bound to as well — one vocabulary for
8366
+ # "the budget cut this run", not one per command.
8367
+ data["_partial"] = _run.status(
8368
+ _stopped_early,
8369
+ rules_partially_run=_rules_partial,
8370
+ rules_not_run=_rules_cut,
8371
+ phases_stopped_inside=_phases_cut,
8372
+ )
8350
8373
 
8351
8374
  # Non-fatal RIS side-effect — persist summary only (not full findings).
8352
8375
  try:
@@ -8899,6 +8922,8 @@ def risk_cmd(
8899
8922
  phases=["audit", "compose"],
8900
8923
  output_path=output_path,
8901
8924
  budget_seconds=_analysis_budget("risk").get("configured_max_seconds"),
8925
+ # C3-83: the budget's origin travels with the budget, into `_partial`.
8926
+ budget_source=_analysis_budget("risk").get("configured_source"),
8902
8927
  writer=_safe_write_file,
8903
8928
  )
8904
8929
  with _expensive_analysis_scope("risk", path, phase):
@@ -8910,10 +8935,18 @@ def risk_cmd(
8910
8935
  profiles=_profile_set(profile),
8911
8936
  # C3-73: 54 minutes with one line on stderr in the field. The
8912
8937
  # factory hands the composer an observer per counted stage.
8938
+ #
8939
+ # C3-87: and the observer carries the deadline. This factory built
8940
+ # its observers with no `stop_when` for four releases, so `risk`
8941
+ # declared `budget_seconds` on the run above and then never asked
8942
+ # whether it was spent — the budget was honoured only between the
8943
+ # two phases. `PhasedRun.exhausted` stays the one authority; what
8944
+ # is new is that the second consumer asks it.
8913
8945
  progress=lambda stage, unit="rule families": _rule_pass_progress(
8914
- _prog, stage, unit
8946
+ _prog, stage, unit, stop_when=_run.exhausted
8915
8947
  ),
8916
8948
  checkpoint=_run.checkpoint,
8949
+ partial_status=lambda **cut: _run.status(_WHY_BUDGET, **cut),
8917
8950
  )
8918
8951
  finally:
8919
8952
  _prog.stop()
@@ -8930,6 +8963,9 @@ def risk_cmd(
8930
8963
  success_msg=(
8931
8964
  f"risk written to {output_path} ({data['shown']} of "
8932
8965
  f"{data['total_defects']} defects composed)"
8966
+ # C3-87: the terminal says it too. A reader who never opens the payload
8967
+ # must not take a truncated ranking for the repository's risk.
8968
+ + (" — PARTIAL, budget exhausted" if data.get("partial") else "")
8933
8969
  ),
8934
8970
  )
8935
8971
  # The complete answer is on disk; the checkpoint that stood in for it is not
sourcecode/phased_run.py CHANGED
@@ -88,6 +88,11 @@ class PhasedRun:
88
88
  phases: "list[str]"
89
89
  output_path: Optional[Path] = None
90
90
  budget_seconds: Optional[float] = None
91
+ #: Where `budget_seconds` came from, verbatim (C3-83) — e.g.
92
+ #: `ASK_MAX_ANALYSIS_SECONDS=420 (process environment)`. A truncated answer
93
+ #: declares everything about *being* truncated and could not say what decided
94
+ #: it, and the variable is inherited from a shell or a runner.
95
+ budget_source: Optional[str] = None
91
96
  #: injected by the CLI so a checkpoint uses the same durable write as the
92
97
  #: final answer (fsync + atomic rename), rather than a second, worse one.
93
98
  writer: Optional[Callable[[Path, str], None]] = None
@@ -126,8 +131,29 @@ class PhasedRun:
126
131
  done = set(self.completed)
127
132
  return [p for p in self.phases if p not in done]
128
133
 
129
- def status(self, why: str) -> dict:
130
- """The block an incomplete answer carries, and nothing else does."""
134
+ def status(
135
+ self,
136
+ why: str,
137
+ *,
138
+ rules_partially_run: "Optional[list[dict]]" = None,
139
+ rules_not_run: "Optional[list[str]]" = None,
140
+ phases_stopped_inside: "Optional[list[str]]" = None,
141
+ ) -> dict:
142
+ """The block an incomplete answer carries, and nothing else does.
143
+
144
+ **C3-87.** The three rule-pass keys used to be assembled by the caller, and
145
+ for four releases there was exactly one caller, so "the caller" and "the
146
+ authority" were the same object by accident. When `risk` was bound to the
147
+ same budget it became clear what that costs: a second consumer either
148
+ duplicates thirty lines of `how_to_read` composition or publishes a
149
+ different vocabulary for the same fact. They live here now, so a command
150
+ that stops inside its rule pass describes it the one way — and a *third*
151
+ consumer gets it by construction.
152
+
153
+ Each key is present only when there is something to say. A run stopped
154
+ between phases, with no family cut, produces exactly the block it produced
155
+ before this seam existed.
156
+ """
131
157
  out: dict[str, Any] = {
132
158
  "schema_version": SCHEMA_VERSION,
133
159
  "partial": True,
@@ -140,6 +166,40 @@ class PhasedRun:
140
166
  }
141
167
  if self.budget_seconds:
142
168
  out["budget_seconds"] = float(self.budget_seconds)
169
+ if self.budget_source:
170
+ out["budget_source"] = self.budget_source
171
+ if rules_partially_run:
172
+ # C3-79: neither run nor not-run. How far it got is what a reader needs
173
+ # in order to decide whether to re-run with more time, and the findings
174
+ # it produced before the cut are in the payload.
175
+ out["rules_partially_run"] = list(rules_partially_run)
176
+ out["how_to_read"] += (
177
+ " One rule family was stopped part-way through its own "
178
+ "population: "
179
+ + "; ".join(
180
+ f"{'/'.join(entry['rule_ids'])} after "
181
+ f"{entry['units_done']} of {entry['units_total']} "
182
+ f"{entry['unit']}"
183
+ for entry in rules_partially_run
184
+ )
185
+ + ". What it found before the cut is reported and is a floor."
186
+ )
187
+ if phases_stopped_inside:
188
+ # C3-71: a phase stopped *inside* is neither completed nor pending, and
189
+ # putting it in `phases_pending` alone would claim none of it ran when
190
+ # most of it did. Independent of the rule keys: a phase can be cut in a
191
+ # walk that evaluates no rules at all, which is how `risk` loses its
192
+ # composition join (C3-87).
193
+ out["phases_stopped_inside"] = list(phases_stopped_inside)
194
+ if rules_not_run:
195
+ # The families it did not reach are named, so the answer states a gap
196
+ # rather than an absence of findings.
197
+ out["rules_not_run"] = list(rules_not_run)
198
+ out["how_to_read"] += (
199
+ " The rule pass was stopped between families: "
200
+ f"{', '.join(rules_not_run)} did not run, so every count here "
201
+ "is a floor over the families that did."
202
+ )
143
203
  return out
144
204
 
145
205
  # -- checkpoints -------------------------------------------------------
@@ -210,6 +270,7 @@ def budget_warning(
210
270
  *,
211
271
  anchors: Optional[str] = None,
212
272
  scope: Optional[str] = None,
273
+ source: Optional[str] = None,
213
274
  ) -> str:
214
275
  """What the operator is told when the budget is under the class floor.
215
276
 
@@ -238,6 +299,10 @@ def budget_warning(
238
299
  f"[ask] budget: {command} is a {cls} analysis and the configured budget "
239
300
  f"is {configured:g}s."
240
301
  ]
302
+ if source:
303
+ # C3-83: name the origin, because a variable inherited from a shell or a
304
+ # runner is the one setting nobody in the conversation remembers choosing.
305
+ lines.append(f" Budget read from {source}.")
241
306
  if anchors:
242
307
  lines.append(f" Measured for this command: {anchors}.")
243
308
  else:
@@ -259,3 +324,24 @@ def budget_warning(
259
324
  "the ones that did not. It is a floor, not a verdict."
260
325
  )
261
326
  return "\n".join(lines) + "\n"
327
+
328
+
329
+ def budget_provenance(command: str, configured: float, source: str) -> str:
330
+ """One line naming where a budget over the class floor came from (C3-83).
331
+
332
+ Above the floor there is no advice to give and there is still a fact worth
333
+ stating. The field measured what its absence costs: the same build answers
334
+ **395 findings / 98 defects** without a budget and **267 / 11** with
335
+ `ASK_MAX_ANALYSIS_SECONDS=420`, and everything about the truncation is
336
+ declared in the payload — `partial`, `counts_are_floor`, a capped confidence
337
+ — while the *reason* the run was cut lives in an environment variable the
338
+ reader of that payload never saw. So it is named where the run happens, once,
339
+ on stderr, next to the command it is about.
340
+ """
341
+ return (
342
+ f"[ask] budget: {command} will stop after {configured:g}s. Budget read "
343
+ f"from {source}.\n"
344
+ " A run that hits it publishes a FLOOR: `summary.partial`, "
345
+ "`summary.counts_are_floor` and `_partial` say so. Unset the variable "
346
+ "for a complete audit.\n"
347
+ )
sourcecode/progress.py CHANGED
@@ -178,6 +178,10 @@ class Progress:
178
178
  self._unit = "files"
179
179
  self._stage = ""
180
180
  self._stage_t0 = time.monotonic()
181
+ #: When the counted state last *advanced* (C3-86). Distinct from
182
+ #: `_stage_t0`, which is when the stage began: a stage can begin, count to
183
+ #: 3 340 of 3 342, and then stand still while an uncounted walk runs.
184
+ self._count_t0 = time.monotonic()
181
185
  #: When a heartbeat line was last written, by either emitter (C3-80). One
182
186
  #: stamp is what keeps the loop and the in-band beat from double-printing.
183
187
  self._last_emit = 0.0
@@ -227,6 +231,7 @@ class Progress:
227
231
  self._total = 0
228
232
  self._stage = ""
229
233
  self._estimate = True
234
+ self._count_t0 = time.monotonic()
230
235
  # C3-80: a new phase is the most informative thing this line can say, and
231
236
  # the phase that follows it may be the long one.
232
237
  self._beat()
@@ -262,6 +267,11 @@ class Progress:
262
267
  rule `_eta_seconds` already applies to a stage that has barely started.
263
268
  """
264
269
  with self._lock:
270
+ # C3-86: read before anything is mutated — the clock the *staleness* of
271
+ # a line is measured against only restarts when the state it reports
272
+ # actually changed.
273
+ if (done, total, stage) != (self._done, self._total, self._stage):
274
+ self._count_t0 = time.monotonic()
265
275
  if stage != self._stage or done < self._done:
266
276
  self._stage = stage
267
277
  self._stage_t0 = time.monotonic()
@@ -407,6 +417,19 @@ class Progress:
407
417
  self._done, self._total, self._unit, self._stage
408
418
  )
409
419
  eta = self._eta_seconds(done, total)
420
+ # C3-86: how long this count has stood still. The field read
421
+ # `3340/3342 java files eta=0.5s` **fourteen minutes** after the
422
+ # counter last advanced, and both halves of that line are claims the
423
+ # run cannot support: it asserts the stage is 99,9 % done *now*, and
424
+ # the rate behind the ETA is computed over a window in which nothing
425
+ # happened — so the longer the stall lasts, the more imminent the
426
+ # finish looks. A count that has not moved for a whole interval says
427
+ # so and withholds the estimate, which is the rule `_eta_seconds`
428
+ # already applies to a stage too young to extrapolate from.
429
+ stalled = time.monotonic() - self._count_t0
430
+ stale = total > 0 and stalled >= self._interval
431
+ if stale:
432
+ eta = None
410
433
  counted = f"{done}/{total} {unit}" if total > 0 else ""
411
434
  if self._line_mode:
412
435
  line = f"[ask] progress phase={phase}"
@@ -415,6 +438,8 @@ class Progress:
415
438
  if counted:
416
439
  line += f" {counted}"
417
440
  line += f" elapsed={_duration(elapsed)}"
441
+ if stale:
442
+ line += f" unchanged_for={_duration(stalled)}"
418
443
  if eta is not None:
419
444
  # Named, not implied: the estimate covers the counted stage.
420
445
  # Nothing here knows the cost of the stages after it, and a
@@ -426,6 +451,8 @@ class Progress:
426
451
  if counted:
427
452
  shown += f" {counted}"
428
453
  tail = f"{elapsed:.1f}s"
454
+ if stale:
455
+ tail += f", unchanged for {_duration(stalled)}"
429
456
  if eta is not None:
430
457
  tail += f", eta {_duration(eta)}"
431
458
  line = f"\r\033[K{frame} {shown} ({tail})"
@@ -27,7 +27,7 @@ from typing import Any, Optional
27
27
  #: The date `__version__` was released, from that version's CHANGELOG entry.
28
28
  #: `tests/test_release_info.py` fails the build if the two disagree, so this is
29
29
  #: a copy of a fact rather than a second authority for it.
30
- RELEASE_DATE = "2026-08-09"
30
+ RELEASE_DATE = "2026-08-10"
31
31
 
32
32
  #: Releases per day, measured over the 30 releases before this one (the same
33
33
  #: computation the test re-runs against the CHANGELOG). Stable across window
@@ -5538,6 +5538,14 @@ def build_repo_ir(
5538
5538
  )
5539
5539
  )
5540
5540
 
5541
+ # C3-85: from here to the end of the build there is no per-file loop, so the
5542
+ # heartbeat kept `linking n/n` on screen — a counter that finished, reading as
5543
+ # a stage still running. Measured on keycloak (5 486 files, warm), the stretch
5544
+ # between the last `linking` tick and the first rule tick was **8,2 s of a 24 s
5545
+ # run**. These stages have no population to count; they are named, which is
5546
+ # what C3-56 does rather than invent a denominator.
5547
+ if progress is not None:
5548
+ progress(0, 0, "assembling the IR")
5541
5549
  spring_summary = _build_spring_summary(all_symbols)
5542
5550
 
5543
5551
  # Deduplicate relations
@@ -5570,6 +5578,8 @@ def build_repo_ir(
5570
5578
  # when `since` is set). Done here, where `root` is available; placed before the
5571
5579
  # XML-security retag so spec routes lacking security get the same treatment.
5572
5580
  if route_diffs_arg is None:
5581
+ if progress is not None:
5582
+ progress(0, 0, "recovering spec-declared routes")
5573
5583
  _routed_fqns_ir = {
5574
5584
  r.get("effective_class")
5575
5585
  for r in (ir.get("route_surface") or [])
@@ -5597,6 +5607,8 @@ def build_repo_ir(
5597
5607
  r'|xmlns:security="http://www\.springframework\.org/schema/security")',
5598
5608
  re.IGNORECASE,
5599
5609
  )
5610
+ if progress is not None:
5611
+ progress(0, 0, "scanning XML security configuration")
5600
5612
  _xml_sec_detected = False
5601
5613
  for _xml_glob in (
5602
5614
  "*security*.xml", "*Security*.xml",
sourcecode/risk.py CHANGED
@@ -788,6 +788,14 @@ class RiskComposer:
788
788
  # caller owns — `(stage, unit) -> RulePassProgress | None` — so this module
789
789
  # never learns what a spinner is and a run nobody watches pays nothing.
790
790
  self._progress = progress
791
+ # C3-87: the observers are kept, not discarded. Each one holds what the
792
+ # operator's budget cut out of the pass it watched, and until this command
793
+ # was bound to the budget there was nothing to keep: `stop_when` was never
794
+ # passed, so `should_stop()` answered False for the life of the run and the
795
+ # deadline could only ever be observed on the boundary between two phases.
796
+ # The field measured what that costs on one machine under one 420 s
797
+ # budget — `spring-audit` 1,94× over, `risk` 6,8× over.
798
+ self._rule_watchers: "list[Any]" = []
791
799
  if findings is None:
792
800
  findings = list(run_tx_audit(
793
801
  self.cir, root=self.root, model=self.model,
@@ -801,7 +809,21 @@ class RiskComposer:
801
809
  )
802
810
  self.findings = findings
803
811
 
812
+ # C3-85: everything from here to the composition is repository-wide work
813
+ # with no population of its own, and it reported nothing — so the line held
814
+ # the last rule family's count while it ran. Each walk is named as it
815
+ # starts, which is what the operator needs in order to tell a slow stage
816
+ # from a hung one, and which is also how the next round gets to say *which*
817
+ # of these walks the wall time is in.
818
+ _setup = self._rule_progress("composing risk factors", "walks")
819
+
820
+ def _named(label: str) -> None:
821
+ if _setup is not None:
822
+ _setup.step(label)
823
+
824
+ _named("inferring the security posture")
804
825
  posture = infer_security_posture(self.cir, root=self.root)
826
+ _named("resolving the endpoint security surface")
805
827
  self.surface = endpoint_security_surface(self.cir, root=self.root, posture=posture)
806
828
  access = (
807
829
  posture.to_dict().get("endpoints", []) if hasattr(posture, "to_dict") else []
@@ -815,8 +837,11 @@ class RiskComposer:
815
837
  # F.4-1. One pass over the α argument-shape atoms and one over the validation
816
838
  # surface: both are repository-wide facts, read once and joined by symbol and
817
839
  # by route, never re-derived per row.
840
+ _named("reading concatenated-query owners")
818
841
  self.concat_owners = _concatenated_query_owners(self.cir)
842
+ _named("reading HTTP-input query sinks")
819
843
  self.http_input_query_sinks = _http_input_query_sinks(self.root, self.cir)
844
+ _named("indexing the validation surface")
820
845
  self.constraint_index = _constraint_index(
821
846
  self.root, self.cir, extract_java_endpoints(self.root)
822
847
  )
@@ -843,6 +868,7 @@ class RiskComposer:
843
868
 
844
869
  # One pass answers both: the access axis (C1-26) and how much of the
845
870
  # request surface each chain configuration file decides (C1-25).
871
+ _named("resolving the conditional bean graph")
846
872
  self.profile_access, self.chain_reach = access_resolution(
847
873
  self.root,
848
874
  self.profiles if self.profiles is not None else set(),
@@ -887,13 +913,58 @@ class RiskComposer:
887
913
  return symbol, "method" if "#" in symbol else "class"
888
914
 
889
915
  def _rule_progress(self, stage: str, unit: str = "rule families") -> "Any":
890
- """The observer for one counted stage, or None when nobody is watching."""
916
+ """The observer for one counted stage, or None when nobody is watching.
917
+
918
+ C3-87: "nobody is watching" now includes the budget. The factory returns an
919
+ observer whenever a heartbeat *or* a deadline exists, so a CI run with no
920
+ TTY — precisely where a budget matters — gets the same stop the terminal
921
+ does. The observer is registered here because this is the one place that
922
+ makes them, so `budget_gap()` cannot miss a pass.
923
+ """
891
924
  if self._progress is None:
892
925
  return None
893
926
  try:
894
- return self._progress(stage, unit)
927
+ watcher = self._progress(stage, unit)
895
928
  except Exception:
896
929
  return None
930
+ if watcher is not None:
931
+ self._rule_watchers.append((stage, watcher))
932
+ return watcher
933
+
934
+ def budget_gap(self) -> dict:
935
+ """What the operator's budget cut out of the rule passes, or `{}`.
936
+
937
+ The two audits report through observers this class made, and each records
938
+ its own cut in the vocabulary `spring-audit` already publishes: families
939
+ that never started (`rules_not_run`) and a family stopped part-way through
940
+ one of its stages (`rules_partially_run`, with `stage_stopped_in`). Read
941
+ back here so the composition can publish the gap rather than a total that
942
+ silently shrank.
943
+
944
+ Only the *rule* passes are read. The composition observer counts defects,
945
+ not rule families, and folding its ids into a rule inventory would be the
946
+ two-populations-under-one-name defect this ledger already carries twice.
947
+ """
948
+ not_run: "list[str]" = []
949
+ partially_run: "list[dict]" = []
950
+ stages: "list[str]" = []
951
+ for stage, watcher in self._rule_watchers:
952
+ if not stage.endswith("rules"):
953
+ continue
954
+ _cut = list(getattr(watcher, "not_run", []) or [])
955
+ _partial = list(getattr(watcher, "partially_run", []) or [])
956
+ if not _cut and not _partial:
957
+ continue
958
+ not_run.extend(_cut)
959
+ partially_run.extend(_partial)
960
+ stages.append(stage)
961
+ if not not_run and not partially_run:
962
+ return {}
963
+ return {
964
+ "rules_not_run": not_run,
965
+ "rules_partially_run": partially_run,
966
+ "stages_stopped_inside": stages,
967
+ }
897
968
 
898
969
  def compose(
899
970
  self,
@@ -1157,6 +1228,7 @@ def _assemble_payload(
1157
1228
  limit: int,
1158
1229
  min_band: str,
1159
1230
  profiles: "Optional[set[str]]",
1231
+ budget_cut: "Optional[dict]" = None,
1160
1232
  ) -> dict:
1161
1233
  """The `risk` answer document for `rows`. One construction, two callers.
1162
1234
 
@@ -1199,6 +1271,21 @@ def _assemble_payload(
1199
1271
  # that sentence, and it belongs where the composition is, not one command away.
1200
1272
  "non_coverage": non_coverage.block("security_surface"),
1201
1273
  }
1274
+ # C3-87 (and C2-31's rule, applied to the second consumer): the counts above
1275
+ # are arithmetic over what was composed, and on a cut run that is a truncated
1276
+ # population. The mark goes where the numbers are — a reader who never opens
1277
+ # `_partial` must not read `total_defects` as a census. A complete run gains
1278
+ # no fields and stays byte-identical.
1279
+ if budget_cut:
1280
+ payload["partial"] = True
1281
+ payload["counts_are_floor"] = True
1282
+ payload["counts_basis"] = (
1283
+ "This run was cut short by the analysis budget, so every count above "
1284
+ "is a FLOOR over what did run — the absence of a risk composed from a "
1285
+ "rule family that never ran is not evidence there is none, and a "
1286
+ "composition stopped part-way leaves defects unranked rather than "
1287
+ "unfound. `_partial` names what was not measured."
1288
+ )
1202
1289
  if len(kept) > limit:
1203
1290
  from sourcecode.degradation import cap_effect
1204
1291
 
@@ -1238,6 +1325,7 @@ def build_risk(
1238
1325
  profiles: "Optional[set[str]]" = None,
1239
1326
  progress: "Optional[Callable[..., Any]]" = None,
1240
1327
  checkpoint: "Optional[Callable[[str, dict], None]]" = None,
1328
+ partial_status: "Optional[Callable[..., dict]]" = None,
1241
1329
  ) -> dict:
1242
1330
  """Compose this build's own audit with reach, access, write effect and shape.
1243
1331
 
@@ -1267,11 +1355,47 @@ def build_risk(
1267
1355
  # C3-73: the join is the other half of this command's wall time, and it has a
1268
1356
  # denominator of its own — one row per defect. Counted with the same seam as
1269
1357
  # the rule passes, so there is one place that knows how to report counted work.
1358
+ # C3-87: what the budget cut out of the two rule passes, read back from the
1359
+ # observers this composition made. Empty on a run that was not cut, which is
1360
+ # every run without `ASK_MAX_ANALYSIS_SECONDS` set.
1361
+ _cut: dict = dict(composer.budget_gap())
1362
+
1270
1363
  def _assemble(_rows: "list[Any]") -> dict:
1271
- return _assemble_payload(
1364
+ payload = _assemble_payload(
1272
1365
  _rows, findings=findings, composer=composer, root=root,
1273
1366
  limit=limit, min_band=min_band, profiles=profiles,
1367
+ budget_cut=_cut or None,
1274
1368
  )
1369
+ if _cut and partial_status is not None:
1370
+ # A phase stopped *inside* is neither completed nor pending: `audit`
1371
+ # when a rule family was cut, `compose` when the join was.
1372
+ _inside = [
1373
+ name
1374
+ for name, hit in (
1375
+ ("audit", bool(_cut.get("rules_not_run"))),
1376
+ ("compose", _cut.get("defects_composed") is not None),
1377
+ )
1378
+ if hit
1379
+ ]
1380
+ payload["_partial"] = partial_status(
1381
+ rules_partially_run=_cut.get("rules_partially_run") or None,
1382
+ rules_not_run=_cut.get("rules_not_run") or None,
1383
+ phases_stopped_inside=_inside or None,
1384
+ )
1385
+ if _cut.get("defects_composed") is not None:
1386
+ # The join is this command's own population, and a run cut inside
1387
+ # it did rank some defects and not others. Neither `phases_completed`
1388
+ # nor `phases_pending` can say that, so it is said here.
1389
+ payload["_partial"]["composition_stopped_after"] = {
1390
+ "defects_composed": _cut["defects_composed"],
1391
+ "defects_total": _cut["defects_total"],
1392
+ "basis": (
1393
+ "The audit ran; the join that ranks its defects was cut. "
1394
+ "The defects not composed were measured and are counted in "
1395
+ "total_defects — they carry no band and are not in `risks`."
1396
+ ),
1397
+ }
1398
+ return payload
1275
1399
 
1276
1400
  if checkpoint is not None:
1277
1401
  # The audit and the posture are paid for; the join has not started. A
@@ -1286,6 +1410,15 @@ def build_risk(
1286
1410
  rows = []
1287
1411
  for _index, defect in enumerate(_defects, start=1):
1288
1412
  if _rows_progress is not None:
1413
+ # C3-87: the join is the other half of this command's wall time, so the
1414
+ # deadline is asked here too — on a whole composed row, which is this
1415
+ # loop's unit, and sampled by the same seam the rule passes use.
1416
+ if _rows_progress.tick(
1417
+ _index - 1, len(_defects), label="composing defects", unit="defects"
1418
+ ):
1419
+ _cut["defects_composed"] = _index - 1
1420
+ _cut["defects_total"] = len(_defects)
1421
+ break
1289
1422
  _rows_progress.rule(_index, len(_defects), str(defect["defect_id"]))
1290
1423
  rows.append(composer.compose(
1291
1424
  defect_id=str(defect["defect_id"]),
sourcecode/rule_pass.py CHANGED
@@ -155,20 +155,37 @@ class RulePassProgress:
155
155
  from the family's duration to a batch's, and the cost of the check is a
156
156
  modulo per unit.
157
157
 
158
+ **C3-82.** The sampling constant belongs to the *clock* and not to the
159
+ *counter*, and until this the two shared it. The field measured what that
160
+ costs: `1856/3342 java files` at 10,0 s, then **13,6 minutes with no line
161
+ at all**, then `3328/3342` — because a report that lands between two batch
162
+ boundaries is never produced, and a stretch of expensive units (or one
163
+ pathological file) is invisible for as long as it lasts. So the count is
164
+ reported on **every** unit and the clock is still read once per batch: the
165
+ emitter throttles itself on an interval, so a per-unit report costs one
166
+ clock read and produces a line only when one is due, while a per-unit
167
+ budget check would buy no resolution the deadline can use.
168
+
158
169
  Never raises: an unreadable clock is not evidence the budget is spent, and
159
170
  a reporting fault is not a reason to stop analysing.
160
171
  """
161
- if done % self.sample_every and done != total:
162
- return False
163
172
  if self.detail_sink is not None:
164
173
  try:
165
174
  self.detail_sink(done, total, label, unit)
166
175
  except Exception:
167
176
  pass
177
+ if done % self.sample_every and done != total:
178
+ return False
168
179
  return self.should_stop()
169
180
 
170
181
  def record_partial(
171
- self, rule_ids: "list[str]", done: int, total: int, *, unit: str = "files"
182
+ self,
183
+ rule_ids: "list[str]",
184
+ done: int,
185
+ total: int,
186
+ *,
187
+ unit: str = "files",
188
+ stage: str = "",
172
189
  ) -> None:
173
190
  """Record a family the budget cut *in flight* (C3-79).
174
191
 
@@ -176,12 +193,29 @@ class RulePassProgress:
176
193
  calling it not-run discards findings that were measured and are published.
177
194
  It gets its own state, with the count it reached, because *how far* is the
178
195
  thing a reader needs in order to decide whether to re-run with more time.
196
+
197
+ **C3-81.** The count is the *stage's*, not the family's, and until this
198
+ seam said so the entry read as its own contradiction. The field measured
199
+ it: `units_done: 3342, units_total: 3342` in a list meaning *"this family
200
+ did not finish"*, on a family that walks four populations in sequence
201
+ (Java sources, then configuration files, then descriptors, then mappers)
202
+ and was cut at the end of the first. Six `DEAD-001` findings really were
203
+ missing, and nothing in the entry could say where from. So the stage is
204
+ named, and `units_basis` states what the two numbers count — which is the
205
+ difference between a reader concluding *"it finished"* and a reader
206
+ concluding *"three populations after this one never started"*.
179
207
  """
180
208
  self.partially_run.append({
181
209
  "rule_ids": list(rule_ids),
182
210
  "units_done": done,
183
211
  "units_total": total,
184
212
  "unit": unit,
213
+ "stage_stopped_in": stage or unit,
214
+ "units_basis": (
215
+ "units_done/units_total count the stage named in "
216
+ "stage_stopped_in, not the whole family: a family cut at the end "
217
+ "of one stage did not run the stages after it."
218
+ ),
185
219
  })
186
220
 
187
221
  def partial_rule_ids(self) -> "list[str]":
@@ -223,14 +257,33 @@ class FamilyBudget:
223
257
  label: str
224
258
  stopped: bool = False
225
259
 
226
- def tick(self, done: int, total: int, *, unit: str = "files") -> bool:
227
- """True when this family must stop. Idempotent once it has said so."""
260
+ def stage(self, label: str) -> None:
261
+ """Name a stretch of this family that has no population to count (C3-85).
262
+
263
+ A family walks several populations in sequence and does uncounted work
264
+ between them — finding the files the next stage will read, for one. That
265
+ work reports nothing, so the line keeps the previous stage's `n/n` on
266
+ screen, which reads as a stage still running rather than one that ended.
267
+ Named, not counted: inventing a denominator for it is the fabrication
268
+ C3-56 refused.
269
+ """
270
+ self.progress.step(f"{self.label}: {label}")
271
+
272
+ def tick(
273
+ self, done: int, total: int, *, unit: str = "files", stage: str = ""
274
+ ) -> bool:
275
+ """True when this family must stop. Idempotent once it has said so.
276
+
277
+ `done` is what the loop has **finished**, so a stage in flight can never
278
+ report its own population as complete (C3-81); `stage` names the walk the
279
+ count belongs to, because a family may walk several in sequence.
280
+ """
228
281
  if self.stopped:
229
282
  return True
230
283
  if self.progress.tick(done, total, label=self.label, unit=unit):
231
284
  self.stopped = True
232
285
  self.progress.record_partial(
233
- list(self.rule_ids), done, total, unit=unit
286
+ list(self.rule_ids), done, total, unit=unit, stage=stage
234
287
  )
235
288
  return True
236
289
  return False
@@ -612,8 +612,13 @@ def scan_security_configuration(
612
612
 
613
613
  out: "list[SecurityConfigObservation]" = []
614
614
  _java_total = len(java_files)
615
- for _seen, rel in enumerate(java_files, start=1):
616
- if budget is not None and budget.tick(_seen, _java_total, unit="java files"):
615
+ for _seen, rel in enumerate(java_files):
616
+ # C3-81: `_seen` is what this walk has *finished*, so the stage cannot
617
+ # report a complete population while it is still being cut, and the stage
618
+ # is named because three more populations follow this one.
619
+ if budget is not None and budget.tick(
620
+ _seen, _java_total, unit="java files", stage="scanning java sources"
621
+ ):
617
622
  return sorted(
618
623
  _shared_across_environments(out),
619
624
  key=lambda o: (o.rule, o.file, o.line),
@@ -633,6 +638,11 @@ def scan_security_configuration(
633
638
  # surface and Java discovery cannot disagree about what is source.
634
639
  from sourcecode.repository_ir import walk_source_tree
635
640
 
641
+ # C3-85: one walk of the whole source tree, between the Java stage that just
642
+ # ended and the configuration stage that has not started. It reported nothing,
643
+ # so the line held `3342/3342 java files` while it ran.
644
+ if budget is not None:
645
+ budget.stage("finding configuration files, descriptors and mappers")
636
646
  _walked_config: "list[str]" = []
637
647
  descriptors: "list[str]" = []
638
648
  mappers: "list[str]" = []
@@ -656,9 +666,10 @@ def scan_security_configuration(
656
666
  if config_files is None:
657
667
  config_files = _walked_config
658
668
  _cfg = sorted(set(config_files))
659
- for _seen, rel in enumerate(_cfg, start=1):
669
+ for _seen, rel in enumerate(_cfg):
660
670
  if budget is not None and budget.tick(
661
- _seen, len(_cfg), unit="configuration files"
671
+ _seen, len(_cfg), unit="configuration files",
672
+ stage="scanning configuration files",
662
673
  ):
663
674
  break
664
675
  try:
@@ -673,9 +684,10 @@ def scan_security_configuration(
673
684
  # deployment descriptors, and a per-environment overlay tree is covered
674
685
  # because the walk finds the file wherever the build put it.
675
686
  _desc = sorted(set(descriptors))
676
- for _seen, rel in enumerate(_desc, start=1):
687
+ for _seen, rel in enumerate(_desc):
677
688
  if budget is not None and budget.tick(
678
- _seen, len(_desc), unit="deployment descriptors"
689
+ _seen, len(_desc), unit="deployment descriptors",
690
+ stage="scanning deployment descriptors",
679
691
  ):
680
692
  break
681
693
  try:
@@ -686,9 +698,9 @@ def scan_security_configuration(
686
698
  out.extend(_scan_descriptor(blank_xml_comments(text), rel))
687
699
 
688
700
  _maps = sorted(set(mappers) - set(descriptors))
689
- for _seen, rel in enumerate(_maps, start=1):
701
+ for _seen, rel in enumerate(_maps):
690
702
  if budget is not None and budget.tick(
691
- _seen, len(_maps), unit="mapper files"
703
+ _seen, len(_maps), unit="mapper files", stage="scanning mapper files",
692
704
  ):
693
705
  break
694
706
  try:
@@ -110,10 +110,13 @@ class _SEC001UnsecuredEndpoint:
110
110
  findings: list[SpringFinding] = []
111
111
 
112
112
  _total = len(cir.endpoints)
113
- for _seen, ep in enumerate(cir.endpoints, start=1):
113
+ for _seen, ep in enumerate(cir.endpoints):
114
114
  # C3-79: the population is the endpoints, so that is what the deadline
115
115
  # is observed on. What was measured before the cut is returned.
116
- if budget is not None and budget.tick(_seen, _total, unit="endpoints"):
116
+ # C3-81: `_seen` counts what is finished, and the walk is named.
117
+ if budget is not None and budget.tick(
118
+ _seen, _total, unit="endpoints", stage="walking endpoints"
119
+ ):
117
120
  break
118
121
  if ep.security is not None and ep.security.policy != "none_detected":
119
122
  continue
@@ -635,12 +638,13 @@ class _GATE00XCustomGateAudit:
635
638
  sources = _read_java_sources(Path(root), cir)
636
639
  findings: list[SpringFinding] = []
637
640
  _token_total = len(tokens)
638
- for _token_seen, token in enumerate(tokens, start=1):
641
+ for _token_seen, token in enumerate(tokens):
639
642
  # C3-79: the gate tokens are this family's population. Each one costs
640
643
  # three passes over the admissible sources, so the boundary between two
641
644
  # tokens is where a deadline can be honoured without a half-built pass.
642
645
  if budget is not None and budget.tick(
643
- _token_seen, _token_total, unit="gate annotations"
646
+ _token_seen, _token_total, unit="gate annotations",
647
+ stage="resolving gate annotations",
644
648
  ):
645
649
  break
646
650
  # C3-61, second measurement. The `token not in source` guard removed
@@ -992,6 +996,11 @@ class SecurityScanner:
992
996
  self.rules_run.extend(rule_ids_of(pattern))
993
997
  if progress is not None:
994
998
  progress.rule(_index, _total, pattern.pattern_id, done=True)
999
+ # C3-85: the tail of the pass. Two walks over every finding the families
1000
+ # produced, after the last family reported and before the next named stage
1001
+ # — inside the field's silence, like the two stages C3-73 named before it.
1002
+ if progress is not None:
1003
+ progress.step("deduplicating and ordering findings")
995
1004
  deduped = deduplicate_findings(all_findings)
996
1005
  return sorted(deduped, key=lambda f: (SEVERITY_ORDER.get(f.severity, 9), f.symbol))
997
1006
 
@@ -1098,8 +1107,9 @@ def run_security_audit(
1098
1107
  _rules_partial = list(getattr(progress, "partially_run", []) or [])
1099
1108
  if _rules_partial:
1100
1109
  _cut = ", ".join(
1101
- f"{'/'.join(entry['rule_ids'])} after {entry['units_done']} of "
1102
- f"{entry['units_total']} {entry['unit']}"
1110
+ f"{'/'.join(entry['rule_ids'])} while {entry['stage_stopped_in']}, "
1111
+ f"after {entry['units_done']} of {entry['units_total']} "
1112
+ f"{entry['unit']}"
1103
1113
  for entry in _rules_partial
1104
1114
  )
1105
1115
  _sec_limitations.append(
@@ -189,10 +189,12 @@ class _TX001ProxyBypass:
189
189
  _seen_ids: set[str] = set()
190
190
  _declared = tx_index.all_declared
191
191
  _total = len(_declared)
192
- for _seen, boundary in enumerate(_declared, start=1):
192
+ for _seen, boundary in enumerate(_declared):
193
193
  # C3-79: the declared boundaries are this family's population.
194
+ # C3-81: counted as finished, and the walk is named.
194
195
  if budget is not None and budget.tick(
195
- _seen, _total, unit="declared boundaries"
196
+ _seen, _total, unit="declared boundaries",
197
+ stage="walking declared boundaries",
196
198
  ):
197
199
  break
198
200
  if boundary.scope != "method":
@@ -793,9 +795,12 @@ class _TX006SelfInvocation:
793
795
  _seen_ids: set[str] = set()
794
796
 
795
797
  _total = len(body_facts)
796
- for _seen, (caller_fqn, atoms) in enumerate(body_facts.items(), start=1):
798
+ for _seen, (caller_fqn, atoms) in enumerate(body_facts.items()):
797
799
  # C3-79: one method body per unit — the population this walk iterates.
798
- if budget is not None and budget.tick(_seen, _total, unit="method bodies"):
800
+ # C3-81: counted as finished, and the walk is named.
801
+ if budget is not None and budget.tick(
802
+ _seen, _total, unit="method bodies", stage="walking method bodies"
803
+ ):
799
804
  break
800
805
  if not isinstance(atoms, list):
801
806
  continue
@@ -1046,8 +1051,9 @@ def run_tx_audit(
1046
1051
  _rules_partial = list(getattr(progress, "partially_run", []) or [])
1047
1052
  if _rules_partial:
1048
1053
  _cut = ", ".join(
1049
- f"{'/'.join(entry['rule_ids'])} after {entry['units_done']} of "
1050
- f"{entry['units_total']} {entry['unit']}"
1054
+ f"{'/'.join(entry['rule_ids'])} while {entry['stage_stopped_in']}, "
1055
+ f"after {entry['units_done']} of {entry['units_total']} "
1056
+ f"{entry['unit']}"
1051
1057
  for entry in _rules_partial
1052
1058
  )
1053
1059
  _tx_limitations.append(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sourcecode
3
- Version: 4.14.0
3
+ Version: 4.16.0
4
4
  Summary: Persistent structural context and ultra-fast repeated analysis for AI coding agents
5
5
  License-File: LICENSE
6
6
  Keywords: agents,ai,codebase,context,developer-tools,llm
@@ -42,7 +42,7 @@ Description-Content-Type: text/markdown
42
42
 
43
43
  **Context · Impact · Migration · Architecture · Review — everything from one structural model.**
44
44
 
45
- ![Version](https://img.shields.io/badge/version-4.14.0-blue)
45
+ ![Version](https://img.shields.io/badge/version-4.16.0-blue)
46
46
  ![Python](https://img.shields.io/badge/python-3.9%2B-green)
47
47
 
48
48
  > **ASK Engine** is the product. The CLI command is **`ask`**. The legacy **`sourcecode`**
@@ -76,13 +76,31 @@ With the right command class, ASK Engine becomes **constant infrastructure** ins
76
76
  **Large-repo controls.** Pass `--progress stderr` (or `--progress file:run.log`)
77
77
  for line heartbeats in a non-TTY supervised run — with the file being read so
78
78
  far and an ETA for the stage being measured. `ASK_PROGRESS` sets the same thing
79
- in the environment. Long repo-wide/deep runs also write durable records under
80
- `~/.sourcecode/runs/<repo-hash>/` by default, not inside the audited repository;
81
- set `ASK_RUNS_IN_REPO=1` only when repo-local `.ask/runs` records are intentional.
82
- Set `ASK_MAX_ANALYSIS_SECONDS=<seconds>` to fail before starting a repo-wide/deep
83
- command whose configured budget is below its safe floor. Use
84
- `ask audit-report --from-risk risk.json` to package an existing risk run instead
85
- of recomputing it.
79
+ in the environment, and the heartbeat is emitted by the analysis itself, not only
80
+ by a timer thread, so a CPU-bound stretch is never silent. `--jobs N` / `-j`
81
+ (env `ASK_JOBS`) sets the worker processes that parse files — default CPU count
82
+ − 1; parallelism only warms the content-addressed parse cache, so the answer is
83
+ byte-identical at any value. It does **not** parallelise the rule pass, which is
84
+ where a repository-wide audit spends most of its time, and a warm cache leaves it
85
+ nothing to parse — so raising it there buys nothing. Long repo-wide/deep runs also write durable records
86
+ under `~/.sourcecode/runs/<repo-hash>/` by default, not inside the audited
87
+ repository; set `ASK_RUNS_IN_REPO=1` only when repo-local `.ask/runs` records are
88
+ intentional. Use `ask audit-report --from-risk risk.json` to package an existing
89
+ risk run instead of recomputing it.
90
+
91
+ **A budget is honoured, never a veto.** `ASK_MAX_ANALYSIS_SECONDS=<seconds>`
92
+ bounds a repo-wide/deep command. A value below the class floor prints what that
93
+ class typically needs and **runs anyway** — refusing to answer is the opposite of
94
+ this product's doctrine. A run that spends its budget stops on the loop that is
95
+ spending the time, not only between phases: a rule family cut in flight is
96
+ published as `rules_partially_run` with how far it reached, families that never
97
+ started are named in `rules_not_run`, `_partial` names the phases that completed
98
+ and the ones that did not, and the findings measured before the cut are kept.
99
+ The answer marks itself where the numbers are — `summary.partial`,
100
+ `counts_are_floor`, `counts_basis` — and `confidence_level` is capped at `low`,
101
+ because the confidence of the findings that exist says nothing about the family
102
+ that did not run. Measured on BroadleafCommerce (2 766 Java files): a 4.5s budget
103
+ stops at 1 408 files and ends at 5.1s.
86
104
 
87
105
  **What a warm actually covers.** `ask cache warm` runs the compact analysis: it rebuilds the
88
106
  shared structural layers (L1/L2 + the Repository Intelligence Snapshot + the shared Canonical
@@ -108,7 +126,7 @@ brew tap haroundominique/sourcecode && brew install sourcecode
108
126
  # pip / pipx
109
127
  pipx install sourcecode # or: pip install sourcecode
110
128
 
111
- ask version # ask 4.14.0 — and, on a build that has aged,
129
+ ask version # ask 4.16.0 — and, on a build that has aged,
112
130
  # how many releases have probably shipped since
113
131
  ```
114
132
 
@@ -204,6 +222,9 @@ ask verify /path/to/repo # then: only new violations block
204
222
  > A repository that starts declaring contracts already violates them somewhere; `ask verify`
205
223
  > is **baseline-relative by default** (`--fail-on new`) so the gate survives contact with
206
224
  > reality instead of being switched off on day one.
225
+ > A pipeline wired before the contracts exist exits 2 (`unverified`, with
226
+ > `unverified_reason` naming which state it is); `--allow-unverified` exits 0 while the
227
+ > verdict stays `unverified` — the declared form of `|| true`, and violations still block.
207
228
  > `ask baseline capture|diff|trend` is a different thing: versioned architectural metrics over
208
229
  > time, for trend reporting rather than blocking.
209
230
 
@@ -324,7 +345,7 @@ from it.
324
345
  | `migrate-recipe` | experimental | the migration report as the OpenRewrite recipe that applies it | only recipes a finding named; the manual remainder published beside them; writes nothing without `--write`, and never a runnable command for an empty recipe list |
325
346
  | `data-exposure` | experimental | which routes can carry the data you labelled, and who reaches them | labels declared in `sourcecode.config.json` — never inferred from a name; `signature` and `call_reach` evidence published apart, field-level flow out of scope (NC-008). With nothing declared, `ask data-exposure /path/to/repo` answers `answered: false` and hands back the file, the key and an example to declare — it never reports zero exposed routes |
326
347
  | `endpoints` | core | every REST endpoint, effective path, security policy, confidence | Spring MVC + JAX-RS (~65 % recall on JAX-RS sub-resource locators). `--compact` answers the exposure census without the rows; `--servlets` lists the servlet-mounted surface as its own population; `--client-usage` says which routes the TS/JS client in this repository actually calls |
327
- | `spring-audit` | core | transactional anomalies + security configuration/exposure + validation gaps | `--ci`, `-f github-comment`; `--table --rule SEC-008 --top-n 20` for human-scale review |
348
+ | `spring-audit` | core | transactional anomalies + security configuration/exposure + validation gaps | `--ci`, `-f github-comment`; `--table --rule SEC-008 --top-n 20` for human-scale review. Every run inventories its own rules — `rules_run`, `rules_not_run`, `rules_partially_run`, `rules_run_count`/`rules_total` — so "all of them" is a fact, not an assumption |
328
349
  | `migrate-check` | core | Boot 2→3 readiness + Java LTS/licensing inventory: located blockers, per-dimension score, effort, detected Java 8/11/17/21/25 evidence and explicit licensing-review signals | `--target-jdk 25` sets the target LTS route; `--blast-radius` orders the re-test plan; `--table --rule MIG-001 --band critical --top-n 20` for blocker triage |
329
350
  | `impact` / `impact-chain` | core | blast radius of a change, to the endpoints it reaches | target the **interface**, not the `Impl` |
330
351
  | `pr-impact` | core | the same, scoped to a PR diff | gating command: `--fail-on`, exit codes |
@@ -499,6 +520,25 @@ range, and errors: **no source code, paths, secrets, or output**. Turn it off ag
499
520
  > default you must remember to disable is the wrong default for third-party code. An explicit
500
521
  > choice you made before is unchanged.
501
522
 
523
+ **A question is not a write — `--no-write` / `ASK_READONLY=1`.** One contract over
524
+ every command: nothing is created inside the analysed repository, and a command
525
+ that cannot answer without writing says so instead of writing. A refusal is never
526
+ silent — each one names the path that was not created, on stderr. The scope is the
527
+ tree you pointed at; the shared caches under `~/.sourcecode` and an `--output` path
528
+ elsewhere on the machine are not refused, because refusing them would make the mode
529
+ unusable without making it safer.
530
+
531
+ Most commands read only, and the few that create anything inside the repository do
532
+ it under `.ask/` and only behind an explicit flag (`baseline`, `migrate-check
533
+ --history`, `verify --update-baseline`, `verify-edit --install-hook`, and `.ask/runs`
534
+ when `ASK_RUNS_IN_REPO=1`). `ask --help` prints that list in full, generated from the
535
+ one authority the battery checks — a write seam added without a row there fails the
536
+ suite.
537
+
538
+ ```bash
539
+ ask spring-audit /path/to/client-repo --no-write -o audit.json
540
+ ```
541
+
502
542
  **Custom security annotations.** Teach `endpoints`, `spring-audit`, and `explain` about
503
543
  project-specific authorization annotations via an optional `sourcecode.config.json` at the
504
544
  repo root (otherwise they report `policy: "none_detected"`):
@@ -1,4 +1,4 @@
1
- sourcecode/__init__.py,sha256=qNowObAMVSm2IHYY_tkZt5xIhyBvBeJ4bDCcnIyty9Q,309
1
+ sourcecode/__init__.py,sha256=5rp02UOw1nxBYqNTQnjILrQlHGCnAIXmjbWqy0Ci4T4,309
2
2
  sourcecode/adaptive_scanner.py,sha256=yJBKjNpkY6bpueYJ2YnRezen3sYZDecEt7WaaNWdqug,9466
3
3
  sourcecode/archetype.py,sha256=CZvRLpkHot_D8D3JFQVorr-EHDJyh0BbS7RnSTqBigM,40499
4
4
  sourcecode/architectural_baseline.py,sha256=4GiMVBLJVHvRKSWQGf2ZVurI1-R0qk5pmaevgvGSgVA,26325
@@ -18,7 +18,7 @@ sourcecode/chain_rules.py,sha256=Bi6UHfgd-GxWswmnHRcPz5jdbAuqka3Zkz_P-MTvqhw,127
18
18
  sourcecode/change_plan.py,sha256=aX1mp2XnlDN-8R2BcTiu8HlkDwQ_4fN1ZLckj0VMXHM,9945
19
19
  sourcecode/cir_graphs.py,sha256=9G0HHj1kw2325IDyzo2OpX73BNswEckecf4MZUXB4JM,12078
20
20
  sourcecode/classifier.py,sha256=JBzPwSSrDG-tUHAbcKB678HRbjLpD-ohzbzzO62mgpo,20114
21
- sourcecode/cli.py,sha256=eiUg6TKullRAlAdRnHTaCXfq3t_oLFRCXp9BTqOTvnc,595227
21
+ sourcecode/cli.py,sha256=TvA4KIhUnm4Pr25B1ISCRSD0OzFdxD9wdP7x9TmKLig,597596
22
22
  sourcecode/client_calls.py,sha256=daRTgbXNUOfkzGXJpVb6A737R_Thhka8vw1_YgxCaLg,13548
23
23
  sourcecode/code_notes_analyzer.py,sha256=EJemNCNc9Dn-1RZYu-aNbK0ELzmsyC4s6FdHi3XyNEI,9392
24
24
  sourcecode/compare.py,sha256=2GdDy0qkDSgmkkpgvoCHitoLj3mt30EeFgJ8PGEXJng,14712
@@ -81,13 +81,13 @@ sourcecode/parse_cache.py,sha256=fhXnoaOgVWwp_8q0ngJ76UZ09qJ6uxDd0jaX8jdezvo,208
81
81
  sourcecode/path_admission.py,sha256=OGNSoluhVh8YYOBZBnACUrkmH4Tp_yxjGLifkcsxQpo,5838
82
82
  sourcecode/path_filters.py,sha256=LmTYq735orssKTIuxTX1mE1FHAXF7dVEJEse35xE1RA,12371
83
83
  sourcecode/perf.py,sha256=vZDEj1wS905peXmREdOniZrWCikElOyPTw-E32zVZAQ,25847
84
- sourcecode/phased_run.py,sha256=-rO31RnNc26oWTxwloURW6PydkMb-UW6jFO-wuUVN6w,10970
84
+ sourcecode/phased_run.py,sha256=zJGOLAuMAzjr71NQAEkXclXkgzTYpBsF4KP5EV2Itdk,15545
85
85
  sourcecode/pipe_contract.py,sha256=PML0Er5d8uDyec4OrdUXuyqHbGPUYndjeaKUjkFz2u4,8369
86
86
  sourcecode/posture.py,sha256=cZ3By8LR-1bHfzyVpCDo2HHSAsCvePOmUc37E9GXYDw,76206
87
87
  sourcecode/pr_comment_renderer.py,sha256=239PmJdf95av_ZW236C7_tvq_ahECeuF9AZxrheJ4OQ,15573
88
88
  sourcecode/pr_impact.py,sha256=KyVBvHCCbgByKA_wqTbO1dSkfi05GEudIAuH4CyDPK4,27395
89
89
  sourcecode/prepare_context.py,sha256=ntB2MSScHtYRKRm9SansA0FrklMnXZmE9_yRXqXk0qc,239264
90
- sourcecode/progress.py,sha256=3aw7mF7JtrRIy29SsORBtF-5LbQY45f_SfRw_wu7vv8,18869
90
+ sourcecode/progress.py,sha256=A4hNoCRgmHofilbIXMoR4vlvoF4gupefTUCuYmceI_E,20547
91
91
  sourcecode/provenance.py,sha256=gLbsLv0gze7k7ChM4i5M3MwKfFPzs8wzM72rUd2C5sM,7342
92
92
  sourcecode/ranking_engine.py,sha256=ZAucq_YX2KkWUuAZf4P0lhtQ_38vEFnUhuGtSZd1S0E,12970
93
93
  sourcecode/readiness_timeline.py,sha256=KjghC1MWH7Tzto97TBR3N0F8FkR87w5z7lZ1QsWvAQE,8506
@@ -95,23 +95,23 @@ sourcecode/readonly.py,sha256=dqGiNVvpfi4Ww8VzwUs6E3SdbqcHL1LFHItIK7IjrM0,5830
95
95
  sourcecode/reconciliation.py,sha256=GU-1PTcVr8zcbtC7BASfpHcZndP9AdboXPNQiBc0fzo,34251
96
96
  sourcecode/redactor.py,sha256=SB4hwIvg8h-hvcqKcDWaZvA-aSyn-at-BIRwa0tUv5E,3227
97
97
  sourcecode/reference_facts.py,sha256=Ns495c6eTmq2SrqPkcUrJUJru4_wj_yRWm4YDeJwves,13440
98
- sourcecode/release_info.py,sha256=dcCjnLmWzkI0LubNKqisdUxm6vwv9V3KP3zR7xloLZQ,5378
98
+ sourcecode/release_info.py,sha256=4Jfym0xPvJpauhXDWLY2An8yIzae5OlAYZeUr24URsc,5378
99
99
  sourcecode/relevance_scorer.py,sha256=0AgEt4KrV73nioMqBgjhGjtY7L2C7L7cSyKtj3IKcrw,9408
100
100
  sourcecode/remedies.py,sha256=9eAuP9ndw78YvuUtOi2VvoeNnVCMh1q0uxfnPIkvx7c,7838
101
101
  sourcecode/rename_refactor.py,sha256=h6dNFlB9aZ_3q6heeHBkgXQeXaT03nvPSsYH6P8qxFg,12965
102
102
  sourcecode/repo_classifier.py,sha256=FG1vaWKdWXsWdl-S8hjVMiTqcwgaRXkDyvK4rPcOGtQ,22681
103
- sourcecode/repository_ir.py,sha256=t1JNnk5g3ksfjkkrxaXZUKMyszw9zWEb6HQd-vlE3SE,367981
103
+ sourcecode/repository_ir.py,sha256=x0u0m-Dlgtcfr7VsggnKRw_mCobe6B7lZMw3U2H1rd8,368709
104
104
  sourcecode/ris.py,sha256=SjjDNMcc9Zr9us9G8ikBVkoPXKK3BpM-dBodSGMh_g4,24783
105
- sourcecode/risk.py,sha256=jfhCIhxiAqHE0CUO3FZK_-m1bwu-o-yEZfQZrhKnvVs,58449
105
+ sourcecode/risk.py,sha256=aSnK369sYcooEm57oaPX3QcCWoElt5YYJrqTtrXUUdo,65486
106
106
  sourcecode/rule_catalog.py,sha256=pTkgkQZ1u7atB3fkQJ9BPWo8lNiamvEuRN7MtRgle0c,5263
107
- sourcecode/rule_pass.py,sha256=qDH-FAc1uJvAN9Ud_7-utHR1oMmR1bpHN0wYzYzWcrA,11121
107
+ sourcecode/rule_pass.py,sha256=xK3Tj5wXOzRaSmteKZD4I-QeyrAy7ZZzB8ItjC3r2II,13986
108
108
  sourcecode/runs.py,sha256=zIxEPz9DBVVCrX958bZR6bODyfH56A6srAyc8LFMs8I,5303
109
109
  sourcecode/runtime_classifier.py,sha256=uTAD6BDCiBLUZEDRfqk718kM4RTT_vAbfkcOI2_Xx58,18432
110
110
  sourcecode/sarif.py,sha256=3hQEegUxIZbojFdY59oB-yKsK-rnafHdTNmYsEP2--A,25984
111
111
  sourcecode/scanner.py,sha256=z3CV0rcGunu0Y8mpNgp07wI7nxT0pxw1BkXRRtI0Rpo,9609
112
112
  sourcecode/schema.py,sha256=aHNXDf8LGyUC8ZDE_VS9kiskC2-Oswhi_WnpdGy6HDw,24897
113
113
  sourcecode/security_config.py,sha256=KblMEoRiEjrIE68YsPaUAFebxFp8UM7MS7lAk5CGD8U,3531
114
- sourcecode/security_config_scan.py,sha256=aH71o6pVju1NJRg_fvz-5vwRzU1nDRLOxYo97xYNWd8,29311
114
+ sourcecode/security_config_scan.py,sha256=vpAglQkh7QxUhY_eR6aOOE5d-aU2tsEd-bE8pMoFz8g,30021
115
115
  sourcecode/security_posture.py,sha256=JH3v8HKBT4oBKaDCrxNOyrlYSleLlxQw_UgRJcvjX4c,51677
116
116
  sourcecode/semantic_analyzer.py,sha256=bpgdC6m0_ftVtRf3rSdwhbhWjnZnGxRXaZVcfe4BbcQ,95414
117
117
  sourcecode/semantic_impact_engine.py,sha256=t09IirGC3JjQDy33JZd1_WKzQVKXkoNl3-XEUr5kjis,20563
@@ -126,9 +126,9 @@ sourcecode/spring_impact.py,sha256=GW77k7KMn13QmzP5520BbVcXSCYlTFeD1bFibhbmzJM,8
126
126
  sourcecode/spring_model.py,sha256=zOAgFmrRbG4a6KLm1TJl55aWMyPNsz3OS3FSczqPG6A,16594
127
127
  sourcecode/spring_profiles.py,sha256=-kwrCK0O-MRjrCp6SA1d31t--o4Tg87iqPVrTwxoIUs,19605
128
128
  sourcecode/spring_properties.py,sha256=kPTk5qAJxdbHc1hclhHAP0O-hCIzBn8PTh3lzl-I4a4,8400
129
- sourcecode/spring_security_audit.py,sha256=5hIZUk5-0rFnLEc3nCB3gDCLLHoxxZoRl3PIAhyBXEs,55299
129
+ sourcecode/spring_security_audit.py,sha256=-W_cs2vl_GgqWtC2OCdJKL8WuYcZQsessdBO0UTIU-8,55866
130
130
  sourcecode/spring_semantic.py,sha256=UrmLg_4gmBVmMqvSnUMgSReVEERVGv3X7LYDBEs6k_o,16598
131
- sourcecode/spring_tx_analyzer.py,sha256=ivKeXFTYAxa5sDoUrjGVf6MeGqKGPHjAOt__w3g0kpw,49134
131
+ sourcecode/spring_tx_analyzer.py,sha256=4pUTaqqlsKRTBUr6tIwYP9oLamHz_IVKmLq3kFcH7Ww,49412
132
132
  sourcecode/summarizer.py,sha256=0aD4x3vgPngqBCEBKGuES1J2Vk5f7mqCm_ZWErwm3js,27025
133
133
  sourcecode/target_admission.py,sha256=wFZ4pzlxhiF6Q6s2lEAZEzcCfj1Y6xNvujjt8MdO0Qo,7154
134
134
  sourcecode/test_gap_ranking.py,sha256=hl-tTyQUXZGJycG1b6npbLeE_DaVHaOsvPwG9qqC5y8,15711
@@ -203,8 +203,8 @@ sourcecode/telemetry/consent.py,sha256=pQdl-QeLl6Gcibn0eWHSKZrm-HYSsjpVqOnjrgFp8
203
203
  sourcecode/telemetry/events.py,sha256=4_yeO58U-Cwc1Qb27VB0_EjhmroY0k91n3_VGxeALB8,2776
204
204
  sourcecode/telemetry/filters.py,sha256=RzxauTz8HliO4BllQnXEXc7zTeqdCZi5MgqGEDuW7OQ,6570
205
205
  sourcecode/telemetry/transport.py,sha256=4gGHsq0WeY9VywEZXA3vUxykfiYnw9uuqfjAAec7F8o,1681
206
- sourcecode-4.14.0.dist-info/METADATA,sha256=Wjh_U4ayRHzad0wUYf4R8zI6jsW12wn-diDUcqw5jTM,39250
207
- sourcecode-4.14.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
208
- sourcecode-4.14.0.dist-info/entry_points.txt,sha256=-JEAdChrK5We51kZcb7OaDcyil-dHBjBPL-NhuO-QY8,89
209
- sourcecode-4.14.0.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
210
- sourcecode-4.14.0.dist-info/RECORD,,
206
+ sourcecode-4.16.0.dist-info/METADATA,sha256=lWzS830gWVniMl2-yHA-C6RhBojPBdBuVNDm8fBkgZc,42089
207
+ sourcecode-4.16.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
208
+ sourcecode-4.16.0.dist-info/entry_points.txt,sha256=-JEAdChrK5We51kZcb7OaDcyil-dHBjBPL-NhuO-QY8,89
209
+ sourcecode-4.16.0.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
210
+ sourcecode-4.16.0.dist-info/RECORD,,