sourcecode 4.10.3__py3-none-any.whl → 4.10.5__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.10.3"
7
+ __version__ = "4.10.5"
@@ -33,7 +33,7 @@ import json
33
33
  import subprocess
34
34
  from datetime import datetime, timezone
35
35
  from pathlib import Path
36
- from typing import TYPE_CHECKING
36
+ from typing import TYPE_CHECKING, Any
37
37
 
38
38
  from sourcecode import __version__ as TOOL_VERSION
39
39
  from sourcecode.architectural_delta import ArchMetrics, diff_metrics, extract_metrics
@@ -107,6 +107,35 @@ def _hotspots(fan_in: dict[str, int], top_n: int) -> list[dict]:
107
107
  return [{"symbol": s, "fan_in": c} for s, c in ranked[:top_n]]
108
108
 
109
109
 
110
+ def _fan_in_to_list(fan_in: dict[str, int]) -> list[dict]:
111
+ """Serialise the full fan-in map as an array of ``{symbol, fan_in}`` objects,
112
+ sorted by symbol — never a JSON object keyed by symbol.
113
+
114
+ C3-62: PowerShell's `ConvertFrom-Json` folds object keys case-insensitively, so
115
+ two distinct Java symbols differing only by case (legal — Java is case-sensitive)
116
+ collapse into one key and abort parsing the *whole* baseline document. `hotspots`
117
+ already used this array shape (`_hotspots`); the full map stored beside it did
118
+ not. An array of objects has no such ceiling on any consumer."""
119
+ return [{"symbol": s, "fan_in": c} for s, c in sorted(fan_in.items())]
120
+
121
+
122
+ def _fan_in_from_stored(value: Any) -> dict[str, int]:
123
+ """Read a stored ``fan_in`` field back into a symbol → count map.
124
+
125
+ Accepts both the array-of-objects shape (C3-62, current) and the legacy
126
+ object-keyed-by-symbol shape (baselines captured before this fix) so an older
127
+ baseline already committed to disk keeps diffing and trending correctly."""
128
+ if isinstance(value, list):
129
+ return {
130
+ str(item["symbol"]): int(item.get("fan_in", 0))
131
+ for item in value
132
+ if isinstance(item, dict) and "symbol" in item
133
+ }
134
+ if isinstance(value, dict):
135
+ return {str(k): int(v) for k, v in value.items()}
136
+ return {}
137
+
138
+
110
139
  def build_baseline(
111
140
  cir: "CanonicalRepositoryIR",
112
141
  *,
@@ -126,7 +155,6 @@ def build_baseline(
126
155
  that share them."""
127
156
  m = extract_metrics(cir)
128
157
  endpoints = sorted([meth, path] for (meth, path) in m.endpoints)
129
- fan_in = dict(sorted(m.fan_in.items()))
130
158
  cycles = sorted((sorted(c) for c in m.cycles), key=lambda mm: (len(mm), mm))
131
159
  return {
132
160
  "schema": ARCH_BASELINE_SCHEMA,
@@ -145,7 +173,7 @@ def build_baseline(
145
173
  },
146
174
  "hotspots": _hotspots(m.fan_in, hotspot_n),
147
175
  "endpoint_surface": endpoints,
148
- "fan_in": fan_in,
176
+ "fan_in": _fan_in_to_list(m.fan_in),
149
177
  "import_cycles": cycles,
150
178
  "env": env if env is not None else collect_env(),
151
179
  "provenance": (
@@ -209,7 +237,7 @@ def baseline_to_metrics(baseline: dict) -> ArchMetrics:
209
237
  endpoint_count=int(totals.get("endpoints", 0)),
210
238
  dependency_edge_count=int(totals.get("dependency_edges", 0)),
211
239
  endpoints=endpoints,
212
- fan_in={str(k): int(v) for k, v in (baseline.get("fan_in") or {}).items()},
240
+ fan_in=_fan_in_from_stored(baseline.get("fan_in")),
213
241
  cycles=frozenset(
214
242
  frozenset(str(m) for m in members)
215
243
  for members in baseline.get("import_cycles", [])
@@ -397,9 +425,10 @@ def build_trend(baselines: list[dict]) -> dict:
397
425
  if isinstance(h, dict) and h.get("symbol"):
398
426
  tracked.add(str(h["symbol"]))
399
427
 
428
+ fan_in_by_baseline = [_fan_in_from_stored(b.get("fan_in")) for b in baselines]
400
429
  hotspot_trends: list[dict] = []
401
430
  for sym in sorted(tracked):
402
- series = [int((b.get("fan_in") or {}).get(sym, 0)) for b in baselines]
431
+ series = [fmap.get(sym, 0) for fmap in fan_in_by_baseline]
403
432
  first, last = series[0], series[-1]
404
433
  hotspot_trends.append({
405
434
  "symbol": sym,
sourcecode/cache_model.py CHANGED
@@ -302,8 +302,48 @@ def render_markdown() -> str:
302
302
  return "\n".join(out)
303
303
 
304
304
 
305
+ def render_summary() -> str:
306
+ """The model as `ask cache model` prints it by default (C3-63).
307
+
308
+ One line per layer and one line per command — every command named (nothing
309
+ curated out, so the same completeness the battery checks against `render_text`
310
+ holds here too), but without the per-command prose (`note`) and timing
311
+ (`measured`) detail that made the default ~2 500 tokens of prose in a CLI whose
312
+ own selling argument is token economy. That detail is still one flag away.
313
+ """
314
+ lines: list[str] = []
315
+ lines.append(
316
+ "Invalidation: every layer keys on the exact tree state — any change to the "
317
+ "analysed files invalidates it, committed or not."
318
+ )
319
+ lines.append("")
320
+ lines.append("Layers")
321
+ id_width = max(len(lyr.id) for lyr in LAYERS)
322
+ for lyr in LAYERS:
323
+ lines.append(f" {lyr.id.ljust(id_width)} {lyr.stores}")
324
+ lines.append("")
325
+ lines.append(f"What a warm gives each command ({REFERENCE_REPOSITORY})")
326
+ cmd_width = max(len(c.command) for c in COMMANDS)
327
+ warm_width = max(len(label) for label in _WARM_LABEL.values())
328
+ for cmd in COMMANDS:
329
+ repeat = "repeat cached" if cmd.repeat else "recomputes"
330
+ layers = ", ".join(cmd.reads) or "none"
331
+ lines.append(
332
+ f" {cmd.command.ljust(cmd_width)} {_WARM_LABEL[cmd.warm].ljust(warm_width)} "
333
+ f"({repeat}) [{layers}]"
334
+ )
335
+ lines.append("")
336
+ lines.append(
337
+ "Full detail (measured timings, per-command notes): ask cache model --full"
338
+ )
339
+ lines.append("Tables for docs: ask cache model --markdown · machine-readable: --json")
340
+ return "\n".join(lines)
341
+
342
+
305
343
  def render_text() -> str:
306
- """The model as `ask cache model` prints it."""
344
+ """The model in full: every layer and command with its measured timing and note.
345
+
346
+ `ask cache model --full`. See `render_summary` for the default."""
307
347
  lines: list[str] = []
308
348
  lines.append("Invalidation — one rule for every layer")
309
349
  lines.append(" Every layer keys on the exact tree state. Any change to the analysed files")
sourcecode/cli.py CHANGED
@@ -15,9 +15,10 @@ from typing import Any, NoReturn, Optional, cast
15
15
  import typer
16
16
 
17
17
  from sourcecode import __version__
18
- from sourcecode.error_schema import INVALID_INPUT_CODE, build_error_envelope
18
+ from sourcecode.error_schema import EXECUTION_FAILED_CODE, INVALID_INPUT_CODE, build_error_envelope
19
19
  from sourcecode.entrypoint_classifier import is_production_entry_point, normalize_entry_point
20
20
  from sourcecode.progress import Progress
21
+ from sourcecode.progress import output_emitted as _progress_output_emitted
21
22
  from sourcecode import perf
22
23
  from sourcecode.caller_metrics import (
23
24
  CALLER_METRIC_RECONCILIATION,
@@ -888,8 +889,27 @@ def _safe_write_file(path: "Path", content: str) -> None:
888
889
  path = Path(path)
889
890
  path.parent.mkdir(parents=True, exist_ok=True)
890
891
  tmp = path.with_name(f".{path.name}.tmp-{os.getpid()}-{time.time_ns()}")
891
- tmp.write_text(content, encoding="utf-8")
892
+ # C3-54 part 2: the rename is atomic, the bytes were not durable. A
893
+ # process torn down between `write_text` and the OS flushing its page
894
+ # cache leaves a file that exists at full length and holds nothing —
895
+ # the same total loss the false `done` line reported as success, one
896
+ # layer down. fsync the data before the rename, then the directory
897
+ # entry, so a run that reports an output file has one.
898
+ with open(tmp, "w", encoding="utf-8") as fh:
899
+ fh.write(content)
900
+ fh.flush()
901
+ os.fsync(fh.fileno())
892
902
  tmp.replace(path)
903
+ try:
904
+ dir_fd = os.open(str(path.parent), os.O_RDONLY)
905
+ try:
906
+ os.fsync(dir_fd)
907
+ finally:
908
+ os.close(dir_fd)
909
+ except (OSError, AttributeError):
910
+ # Directory fsync is POSIX-only; on Windows the rename is already
911
+ # ordered against the data write. Never fail a good run over it.
912
+ pass
893
913
  try:
894
914
  from sourcecode.runs import active_run
895
915
  run = active_run()
@@ -1213,6 +1233,9 @@ def _emit_command_output(
1213
1233
  # The payload is on stdout here, so this cannot join it; it is a
1214
1234
  # notice and takes the terminal gate rather than dirtying a capture.
1215
1235
  _notice("✓ copied to clipboard")
1236
+ # The answer has been handed over. Any `done (Xs)` a command deferred is now
1237
+ # a true claim and prints here — never before this line (C3-54).
1238
+ _progress_output_emitted()
1216
1239
 
1217
1240
 
1218
1241
  def _emit_text_output(text: str, copy: bool) -> None:
@@ -1228,6 +1251,7 @@ def _emit_text_output(text: str, copy: bool) -> None:
1228
1251
  typer.echo(text)
1229
1252
  if copy and _copy_to_clipboard(text):
1230
1253
  _notice("✓ copied to clipboard")
1254
+ _progress_output_emitted()
1231
1255
 
1232
1256
 
1233
1257
  class _TextAnswer:
@@ -1257,6 +1281,7 @@ class _TextAnswer:
1257
1281
  def done(self, copy: bool) -> None:
1258
1282
  if copy and self._lines and _copy_to_clipboard(self.text):
1259
1283
  _notice("✓ copied to clipboard")
1284
+ _progress_output_emitted()
1260
1285
 
1261
1286
 
1262
1287
  # H-06: Intercept Click-level UsageError (unknown options, bad args) and emit JSON.
@@ -4825,8 +4850,15 @@ def repo_ir_cmd(
4825
4850
  size_bytes = len(raw_bytes)
4826
4851
  if gzip_output:
4827
4852
  import gzip as _gzip
4853
+ # Same durability rule as `_safe_write_file`: the bytes are on disk
4854
+ # before anything says they are (C3-54).
4828
4855
  with _gzip.open(output_path, "wb") as _gz:
4829
4856
  _gz.write(raw_bytes)
4857
+ _gz.flush()
4858
+ try:
4859
+ os.fsync(_gz.fileno())
4860
+ except (OSError, AttributeError):
4861
+ pass
4830
4862
  compressed_kb = output_path.stat().st_size // 1024
4831
4863
  size_kb = size_bytes // 1024
4832
4864
  # C3-26: when the payload went to a file, this confirmation IS the
@@ -4837,7 +4869,9 @@ def repo_ir_cmd(
4837
4869
  f"IR written to {output_path} ({compressed_kb}KB gzip, {size_kb}KB uncompressed)"
4838
4870
  )
4839
4871
  else:
4840
- output_path.write_bytes(raw_bytes)
4872
+ # Was a bare `write_bytes`: neither atomic nor durable, and the only
4873
+ # `-o` write in the CLI that skipped the shared seam (C3-54).
4874
+ _safe_write_file(output_path, output)
4841
4875
  size_kb = size_bytes // 1024
4842
4876
  if summary_only:
4843
4877
  typer.echo(
@@ -4850,6 +4884,7 @@ def repo_ir_cmd(
4850
4884
  f"IR written to {output_path} "
4851
4885
  f"({size_kb}KB, {n_nodes} nodes, {n_edges} edges)"
4852
4886
  )
4887
+ _progress_output_emitted()
4853
4888
  else:
4854
4889
  if gzip_output:
4855
4890
  _emit_error_json(
@@ -7402,7 +7437,14 @@ def spring_audit_cmd(
7402
7437
  except Exception:
7403
7438
  pass
7404
7439
  finally:
7405
- _prog.finish()
7440
+ # C3-54: `done` must not claim the answer exists before it does. The
7441
+ # field log showed `[ask] done (6.8m)` printed, then the process torn
7442
+ # down before `-o` was written — a false success on 6.8 minutes of
7443
+ # CPU. `stop()` here only clears the spinner (satisfies the standing
7444
+ # rule that a raised error must never print under a live spinner
7445
+ # frame); it prints no completion line. `_prog.finish()` runs below,
7446
+ # after the render AND the write have actually happened.
7447
+ _prog.stop()
7406
7448
  if table:
7407
7449
  output = _render_spring_audit_table(
7408
7450
  combined, rule_filter=rule_filter, top_n=top_n, min_severity=min_severity
@@ -7415,6 +7457,7 @@ def spring_audit_cmd(
7415
7457
  _total = combined.summary.get("total_findings", 0)
7416
7458
  _emit_command_output(output, output_path, copy,
7417
7459
  success_msg=f"Spring audit written to {output_path} ({_total} findings)")
7460
+ _prog.finish()
7418
7461
 
7419
7462
  if ci and combined.findings:
7420
7463
  raise typer.Exit(code=1)
@@ -9453,18 +9496,21 @@ def pr_impact_cmd(
9453
9496
  # --files accepts three shapes: a list file, '-' for stdin, or an inline
9454
9497
  # comma-separated list. The list-file shape was the only documented one and
9455
9498
  # the only one the error hint mentioned.
9456
- _files_raw = str(files).strip()
9499
+ # Both byte-carrying shapes decode through `text_input`: stdin and list files
9500
+ # arrive as somebody else's shell wrote them, and the locale codepage is not
9501
+ # evidence of their encoding (C3-58).
9502
+ from sourcecode import text_input as _text_input
9503
+
9504
+ _files_raw = str(files).replace(_text_input.BOM_CHAR, "").strip()
9457
9505
  if _files_raw == "-":
9458
- changed_files = [ln.strip() for ln in sys.stdin.read().splitlines() if ln.strip()]
9506
+ changed_files = _text_input.parse_path_lines(_text_input.read_stdin_text())
9459
9507
  _files_desc = "stdin"
9460
9508
  else:
9461
9509
  _files_path = Path(_files_raw)
9462
9510
  if _files_path.is_file():
9463
- changed_files = [
9464
- line.strip()
9465
- for line in _files_path.read_text(encoding="utf-8").splitlines()
9466
- if line.strip()
9467
- ]
9511
+ changed_files = _text_input.parse_path_lines(
9512
+ _text_input.read_text_file(_files_path)
9513
+ )
9468
9514
  _files_desc = str(_files_path)
9469
9515
  elif all(
9470
9516
  p.strip().lower().endswith(".java")
@@ -10301,6 +10347,9 @@ def modernize_cmd(
10301
10347
  sys.stdout.buffer.flush()
10302
10348
  except AttributeError:
10303
10349
  sys.stdout.write(output + "\n")
10350
+ # This branch predates `_emit_command_output`, so it also has to release the
10351
+ # deferred completion line itself (C3-54).
10352
+ _progress_output_emitted()
10304
10353
 
10305
10354
  if copy:
10306
10355
  _copy_to_clipboard(output)
@@ -11569,12 +11618,33 @@ def mcp_status(copy: bool = _copy_option()) -> None:
11569
11618
  _answer.say("")
11570
11619
 
11571
11620
  # Stage 1: Dependencies
11621
+ # C3-57: check the symbol this CLI actually consumes (FastMCP), not just
11622
+ # whether the root `mcp` package imports — `mcp>=2.0` imports cleanly but
11623
+ # has no `mcp.server.fastmcp` module, so importing only the root package
11624
+ # reports a green check for a build that will fail on `mcp serve`/`mcp list-tools`.
11625
+ def _installed_mcp_version() -> str:
11626
+ # The `mcp` package does not set `__version__`; read installed
11627
+ # distribution metadata instead, and fall back gracefully off a
11628
+ # normal install (editable installs, vendored copies, etc.).
11629
+ try:
11630
+ from importlib.metadata import version as _dist_version
11631
+ return _dist_version("mcp")
11632
+ except Exception:
11633
+ return "unknown"
11634
+
11572
11635
  try:
11573
- import mcp as _mcp_pkg # noqa: F401
11574
- _answer.say("Dependencies ✓ installed")
11636
+ from mcp.server.fastmcp import FastMCP # noqa: F401
11637
+ _mcp_ver = _installed_mcp_version()
11638
+ _answer.say(f"Dependencies ✓ installed (mcp {_mcp_ver})")
11575
11639
  except ImportError:
11576
- _answer.say("Dependencies ✗ missing")
11577
- _answer.say(" Fix: pip install sourcecode[mcp]")
11640
+ try:
11641
+ import mcp as _mcp_pkg # noqa: F401
11642
+ _mcp_ver = _installed_mcp_version()
11643
+ _answer.say(f"Dependencies ✗ mcp {_mcp_ver} incompatible (requires mcp>=1.2,<2)")
11644
+ _answer.say(" Fix: pip install 'sourcecode[mcp]' --upgrade")
11645
+ except ImportError:
11646
+ _answer.say("Dependencies ✗ missing")
11647
+ _answer.say(" Fix: pip install sourcecode[mcp]")
11578
11648
  _answer.say("")
11579
11649
 
11580
11650
  clients = detect_clients()
@@ -11789,9 +11859,32 @@ def mcp_list_tools(
11789
11859
  import asyncio
11790
11860
  import json as _json
11791
11861
 
11792
- from sourcecode.mcp.server import mcp as _mcp
11862
+ # C3-57: this command previously let a raw Python traceback through when
11863
+ # the installed `mcp` package was incompatible (e.g. mcp>=2.0, which has
11864
+ # no mcp.server.fastmcp) — the only command in the catalogue to do so.
11865
+ # Route failures through the same structured error contract as every
11866
+ # other command instead.
11867
+ try:
11868
+ from sourcecode.mcp.server import mcp as _mcp
11869
+ tools = asyncio.run(_mcp.list_tools())
11870
+ except ImportError as exc:
11871
+ _emit_error_json(
11872
+ EXECUTION_FAILED_CODE,
11873
+ f"MCP server dependencies are not importable: {exc}",
11874
+ hint="Run 'ask mcp status' to check the installed mcp package version — "
11875
+ "this CLI requires mcp>=1.2,<2. Fix: pip install 'sourcecode[mcp]' --upgrade",
11876
+ expected="mcp>=1.2,<2 installed with mcp.server.fastmcp.FastMCP importable.",
11877
+ )
11878
+ raise typer.Exit(code=1)
11879
+ except Exception as exc:
11880
+ _emit_error_json(
11881
+ EXECUTION_FAILED_CODE,
11882
+ f"Failed to list MCP tools: {type(exc).__name__}: {exc}",
11883
+ hint="Run 'ask mcp status' to check MCP server health.",
11884
+ expected="A list of registered MCP tools.",
11885
+ )
11886
+ raise typer.Exit(code=1)
11793
11887
 
11794
- tools = asyncio.run(_mcp.list_tools())
11795
11888
  tools_sorted = sorted(tools, key=lambda t: t.name)
11796
11889
 
11797
11890
  if json_output:
@@ -12057,6 +12150,11 @@ def cache_model_cmd(
12057
12150
  path: Path = typer.Argument(Path("."), help="Repository path for CLI consistency (model is global)."),
12058
12151
  json_output: bool = typer.Option(False, "--json", help="Output as JSON."),
12059
12152
  markdown: bool = typer.Option(False, "--markdown", help="Output the tables published in the user guide."),
12153
+ full: bool = typer.Option(
12154
+ False, "--full",
12155
+ help="Full prose: every command's measured timing and note (~2.5k tokens). "
12156
+ "Default is a compact one-line-per-command summary.",
12157
+ ),
12060
12158
  copy: bool = _copy_option(),
12061
12159
  ) -> None:
12062
12160
  """What each cache layer stores, what invalidates it, and what a warm helps.
@@ -12070,6 +12168,10 @@ def cache_model_cmd(
12070
12168
 
12071
12169
  One rule covers invalidation: every layer keys on the exact tree state, so any
12072
12170
  change to the analysed files invalidates it, committed or not.
12171
+
12172
+ \b
12173
+ Default output is a compact summary (C3-63) — every command named, without the
12174
+ per-command measured timing and note. Pass --full for that detail.
12073
12175
  """
12074
12176
  from sourcecode import cache_model as _cmodel
12075
12177
 
@@ -12089,7 +12191,8 @@ def cache_model_cmd(
12089
12191
  f"Scope: {Path(path).resolve()} (model is global; path accepted for "
12090
12192
  "cache command grammar consistency)\n\n"
12091
12193
  )
12092
- _emit_text_output(_prefix + _cmodel.render_text(), copy)
12194
+ _body = _cmodel.render_text() if full else _cmodel.render_summary()
12195
+ _emit_text_output(_prefix + _body, copy)
12093
12196
 
12094
12197
 
12095
12198
  @cache_app.command("freshness", short_help="RIS freshness relative to the current git HEAD")
sourcecode/pr_impact.py CHANGED
@@ -228,6 +228,43 @@ def _suffix_candidates(norm: str, file_class_index: dict[str, list[str]]) -> lis
228
228
  return shorter_given or longer_given
229
229
 
230
230
 
231
+ def _exists_on_disk(norm: str, root: Path) -> bool:
232
+ """True when the given path names a file that is actually there."""
233
+ try:
234
+ candidate = Path(norm)
235
+ if candidate.is_absolute():
236
+ return candidate.is_file()
237
+ return (root / norm).is_file()
238
+ except OSError:
239
+ # A path the OS refuses to even stat (illegal characters after a bad
240
+ # decode, a name past MAX_PATH) is not on disk as far as we can tell.
241
+ return False
242
+
243
+
244
+ def _unresolved_warning(path_str: str, norm: str, root: Path) -> str:
245
+ """Name the right thing to check for a changed file that mapped to nothing.
246
+
247
+ The two causes need opposite investigations and the single old message sent
248
+ every operator to the wrong one. A path that is *not there* is a path
249
+ problem — the field case was a BOM and cp1252 mojibake reaching us through
250
+ stdin (C3-58), and the message said "not in CIR", so the operator went
251
+ looking at analysis coverage. A path that *is* there carries no class
252
+ symbols, which is a fact about the file.
253
+ """
254
+ if _exists_on_disk(norm, root):
255
+ return (
256
+ f"No Spring classes found for '{path_str}' — the file exists but "
257
+ "contributes no class symbols to the CIR (interface-free package-info, "
258
+ "an empty file, or a file excluded from analysis)."
259
+ )
260
+ return (
261
+ f"No file at '{path_str}' under the analyzed repository — nothing was "
262
+ "matched, and this is a path problem, not an analysis gap. Check the path "
263
+ "form (relative to the repo root) and its encoding: a byte-order mark or a "
264
+ "non-UTF-8 pipe corrupts accented names."
265
+ )
266
+
267
+
231
268
  def _resolve_changed_files(
232
269
  file_list: list[str],
233
270
  file_class_index: dict[str, list[str]],
@@ -293,10 +330,7 @@ def _resolve_changed_files(
293
330
 
294
331
  if not candidates:
295
332
  unresolved_java.append(norm)
296
- warnings.append(
297
- f"No Spring classes found for '{path_str}' — "
298
- "file not in CIR or has no class symbols."
299
- )
333
+ warnings.append(_unresolved_warning(path_str, norm, root))
300
334
 
301
335
  for cls in candidates:
302
336
  if cls not in seen_classes:
sourcecode/progress.py CHANGED
@@ -3,6 +3,18 @@
3
3
  Writes only to stderr. Zero-cost when stderr is not a TTY or CI is detected,
4
4
  unless ASK_PROGRESS=1 explicitly requests line heartbeats for supervised jobs.
5
5
  Thread-safe; stop() is idempotent.
6
+
7
+ `done (Xs)` is a claim, and C3-54 is what happens when it is made early: the
8
+ field log printed `[ask] done (6.8m)` and the output directory held no
9
+ `f3-audit.json`, because the completion line was emitted when the *analysis*
10
+ finished — before the render and the write — and the process was torn down in
11
+ that window. 6.8 minutes of CPU, zero recoverable bytes, reported as success.
12
+
13
+ So the line is not printed where `finish()` is called. `finish()` claims the
14
+ work is done; the line is emitted when the **answer has actually been handed
15
+ over**, which the CLI signals through `output_emitted()` at its output seams. A
16
+ run that is killed between the two prints nothing, which is the honest outcome:
17
+ no answer, no completion line.
6
18
  """
7
19
  from __future__ import annotations
8
20
 
@@ -14,6 +26,40 @@ from typing import Optional
14
26
 
15
27
  _FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
16
28
 
29
+ #: Consecutive failed heartbeat writes before the loop gives up on the stream.
30
+ _MAX_WRITE_FAILURES = 5
31
+
32
+ # --- Completion registry ----------------------------------------------------
33
+ # Module state, not per-instance: the emitter of the answer (the CLI output
34
+ # seam) and the owner of the completion line (a Progress somewhere up the call
35
+ # stack) never see each other.
36
+ _registry_lock = threading.Lock()
37
+ _output_events = 0
38
+ _pending_done: "list[Progress]" = []
39
+
40
+
41
+ def output_emitted() -> None:
42
+ """The command's answer has been written. Release any deferred `done` line.
43
+
44
+ Called from every CLI output seam. Safe to call when no Progress is running
45
+ and safe to call more than once per run.
46
+ """
47
+ global _output_events
48
+ with _registry_lock:
49
+ _output_events += 1
50
+ released = list(_pending_done)
51
+ _pending_done.clear()
52
+ for prog in released:
53
+ prog._print_done()
54
+
55
+
56
+ def _reset_for_tests() -> None:
57
+ """Drop registry state between in-process test runs."""
58
+ global _output_events
59
+ with _registry_lock:
60
+ _output_events = 0
61
+ _pending_done.clear()
62
+
17
63
 
18
64
  def _terminal_capable() -> bool:
19
65
  if os.environ.get("ASK_PROGRESS", "").lower() in ("1", "true", "yes", "on"):
@@ -57,6 +103,10 @@ class Progress:
57
103
  self._stop_event = threading.Event()
58
104
  self._thread: Optional[threading.Thread] = None
59
105
  self._stopped = False
106
+ self._done_printed = False
107
+ self._outputs_at_start = 0
108
+ self._elapsed_at_finish = 0.0
109
+ self._write_failures = 0
60
110
 
61
111
  @property
62
112
  def elapsed(self) -> float:
@@ -67,6 +117,9 @@ class Progress:
67
117
  with self._lock:
68
118
  self._phase = phase
69
119
  self._stopped = False
120
+ self._done_printed = False
121
+ with _registry_lock:
122
+ self._outputs_at_start = _output_events
70
123
  if not self._enabled:
71
124
  return self
72
125
  self._stop_event.clear()
@@ -95,14 +148,41 @@ class Progress:
95
148
  return elapsed
96
149
 
97
150
  def finish(self) -> None:
98
- """Stop spinner and print a completion line to stderr."""
99
- elapsed = self.stop()
151
+ """Stop the spinner and claim completion — as soon as that is true.
152
+
153
+ If the command has already handed over its answer (the `spring-audit`
154
+ shape: `finish()` called after the write), the line prints here. If it
155
+ has not, the line waits for `output_emitted()`. It is never printed on a
156
+ run that dies in between, and never on a run that raised: `finally:
157
+ finish()` on an exception path leaves a claim pending that nothing
158
+ releases (C3-54).
159
+ """
160
+ self._elapsed_at_finish = self.stop()
100
161
  if not self._enabled:
101
162
  return
163
+ with _registry_lock:
164
+ if _output_events > self._outputs_at_start:
165
+ already_emitted = True
166
+ else:
167
+ already_emitted = False
168
+ if self not in _pending_done:
169
+ _pending_done.append(self)
170
+ if already_emitted:
171
+ self._print_done()
172
+
173
+ def _print_done(self) -> None:
174
+ """Write the completion line once, for an answer that now exists."""
175
+ if self._done_printed or not self._enabled:
176
+ return
177
+ self._done_printed = True
178
+ elapsed = getattr(self, "_elapsed_at_finish", self.elapsed)
102
179
  t = f"{elapsed:.1f}s" if elapsed < 60 else f"{elapsed / 60:.1f}m"
103
180
  prefix = "[ask] " if self._line_mode else "✓ "
104
- sys.stderr.write(f"{prefix}done ({t})\n")
105
- sys.stderr.flush()
181
+ try:
182
+ sys.stderr.write(f"{prefix}done ({t})\n")
183
+ sys.stderr.flush()
184
+ except Exception:
185
+ pass
106
186
 
107
187
  def _loop(self) -> None:
108
188
  idx = 0
@@ -120,6 +200,19 @@ class Progress:
120
200
  try:
121
201
  sys.stderr.write(line)
122
202
  sys.stderr.flush()
123
- except Exception:
203
+ self._write_failures = 0
204
+ except ValueError:
205
+ # The stream is closed — the run is ending. Nothing to retry.
124
206
  break
207
+ except Exception:
208
+ # C3-56: a single failed write used to end the heartbeat for the
209
+ # rest of the run, and the operator's only remaining way to tell
210
+ # work from a hang was reading the worker's CPU time. One bad
211
+ # write is not evidence that the next one fails: on Windows a
212
+ # redirected stderr can reject a write transiently. Give up only
213
+ # when the stream refuses repeatedly.
214
+ self._write_failures += 1
215
+ if self._write_failures >= _MAX_WRITE_FAILURES:
216
+ break
217
+ continue
125
218
  idx += 1
sourcecode/provenance.py CHANGED
@@ -105,8 +105,6 @@ def _claims_from_rows(command: str, payload: dict[str, Any]) -> list[dict[str, A
105
105
  if row.get("rule_ids"):
106
106
  claim["rule_ids"] = row["rule_ids"]
107
107
  claims.append(claim)
108
- if len(claims) >= _MAX_CLAIMS:
109
- return claims
110
108
  return claims
111
109
 
112
110
 
@@ -166,9 +164,16 @@ def build_evidence_manifest(
166
164
  ) -> dict[str, Any]:
167
165
  """Build a machine-readable provenance index for an existing payload."""
168
166
  root = Path(root).resolve()
169
- claims = _claims_from_rows(command, payload)
170
- claims.extend(_claims_from_java_lts(command, payload))
171
- claims = claims[:_MAX_CLAIMS]
167
+ # I-2: measure the whole population, THEN cut it. `_claims_from_rows` used to
168
+ # return the moment it reached the cap, so the number of claims the payload
169
+ # actually carries was never computed and could not be published — the manifest
170
+ # reported the cap itself as `total_indexed`, which is the exact shape ("the
171
+ # published count is the size of the rendering") this module exists to make
172
+ # auditable. Building every claim is arithmetic over rows already in memory.
173
+ all_claims = _claims_from_rows(command, payload)
174
+ all_claims.extend(_claims_from_java_lts(command, payload))
175
+ total_claims = len(all_claims)
176
+ claims = all_claims[:_MAX_CLAIMS]
172
177
  sources = sorted({
173
178
  str((claim.get("source") or {}).get("path"))
174
179
  for claim in claims
@@ -189,11 +194,14 @@ def build_evidence_manifest(
189
194
  "run": run,
190
195
  "claims": claims,
191
196
  "sources": [{"path": path} for path in sources],
192
- "claims_cap": (
193
- {"total_indexed": _MAX_CLAIMS, "capped": True}
194
- if len(claims) >= _MAX_CLAIMS else
195
- {"total_indexed": len(claims), "capped": False}
196
- ),
197
+ # `total` is the measurement; `total_indexed` is the size of this manifest.
198
+ # `capped` is strictly `total > cap`: a payload with exactly `_MAX_CLAIMS`
199
+ # claims had nothing removed and must not report that it did.
200
+ "claims_cap": {
201
+ "total": total_claims,
202
+ "total_indexed": min(total_claims, _MAX_CLAIMS),
203
+ "capped": total_claims > _MAX_CLAIMS,
204
+ },
197
205
  "embedded_manifests": _embedded_manifests(payload),
198
206
  "limits": {
199
207
  "statement": (
@@ -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-05"
30
+ RELEASE_DATE = "2026-08-07"
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
@@ -21,7 +21,7 @@ import subprocess
21
21
  from collections import deque
22
22
  from dataclasses import dataclass, field
23
23
  from pathlib import Path
24
- from typing import Any, Iterable, Optional
24
+ from typing import Any, Iterable, Iterator, Optional
25
25
 
26
26
  from sourcecode.caller_metrics import CALLER_METRIC_RECONCILIATION, caller_classes
27
27
  from sourcecode.degradation import cap_effect as _cap_effect
@@ -5634,6 +5634,43 @@ _VENDOR_DIRS: frozenset[str] = frozenset({
5634
5634
  "generated-resources",
5635
5635
  })
5636
5636
 
5637
+ #: Metadata trees that hold no repository source at all. Kept apart from
5638
+ #: `_VENDOR_DIRS` because that set answers "is this file part of the product";
5639
+ #: this one answers "is there anything here worth opening". `.git` alone holds
5640
+ #: 5 542 loose objects in this repository, and every unpruned `rglob` visits
5641
+ #: every one of them.
5642
+ _METADATA_DIRS: frozenset[str] = frozenset({
5643
+ ".git", ".hg", ".svn", ".idea", ".vscode", "__pycache__",
5644
+ })
5645
+
5646
+ #: One ignore set for every tree walk that is looking for source (C3-64). A
5647
+ #: second, hand-kept list beside this one is how two surfaces come to disagree
5648
+ #: about what counts as source.
5649
+ PRUNED_TREE_DIRS: frozenset[str] = _VENDOR_DIRS | _METADATA_DIRS
5650
+
5651
+
5652
+ def walk_source_tree(
5653
+ root: Path, *, prune: frozenset[str] = PRUNED_TREE_DIRS,
5654
+ ) -> "Iterator[Path]":
5655
+ """Yield every file under `root`, never descending into a pruned directory.
5656
+
5657
+ `Path.rglob` has no way to skip a subtree: it walks the build output and the
5658
+ VCS metadata and then filters by name, so the cost of a scan is set by what
5659
+ the build wrote rather than by what the developer did. Measured on
5660
+ openmrs-core, 47 % of all tree entries live under directories in `prune`
5661
+ (C3-64).
5662
+
5663
+ Yields absolute paths, and never raises: an unreadable directory is skipped
5664
+ the same way the previous `rglob` calls swallowed `OSError`.
5665
+ """
5666
+ import os
5667
+
5668
+ for dirpath, dirnames, filenames in os.walk(root, onerror=lambda _e: None):
5669
+ dirnames[:] = [d for d in dirnames if d not in prune]
5670
+ base = Path(dirpath)
5671
+ for name in filenames:
5672
+ yield base / name
5673
+
5637
5674
 
5638
5675
  def apply_ir_size_limits(
5639
5676
  ir: dict,
@@ -609,13 +609,36 @@ def scan_security_configuration(
609
609
  out.extend(_scan_commented_controls(source, rel, xml=False))
610
610
  out.extend(_scan_java(blank_java_comments(source), rel))
611
611
 
612
+ # C3-64 — one pruned walk for all three populations. This used to be five
613
+ # `rglob` calls (one per config suffix, one for descriptors, one for
614
+ # mappers), none of which could skip a subtree: the cost was set by the size
615
+ # of `target/`, `node_modules/` and `.git` rather than by the source. The
616
+ # ignore set is the walker's own (`repository_ir.PRUNED_TREE_DIRS`), so this
617
+ # surface and Java discovery cannot disagree about what is source.
618
+ from sourcecode.repository_ir import walk_source_tree
619
+
620
+ _walked_config: "list[str]" = []
621
+ descriptors: "list[str]" = []
622
+ mappers: "list[str]" = []
623
+ for path in walk_source_tree(root):
624
+ name = path.name
625
+ lower = name.lower()
626
+ try:
627
+ rel_posix = path.relative_to(root).as_posix()
628
+ except ValueError:
629
+ continue
630
+ if name.startswith("application") and name.endswith(_CONFIG_SUFFIXES):
631
+ _walked_config.append(rel_posix)
632
+ elif lower.endswith(".xml"):
633
+ # A descriptor that also ends in `Mapper.xml` stays a descriptor —
634
+ # the old code took the same side by subtracting one set from the other.
635
+ if lower in _DESCRIPTOR_NAMES or lower.endswith(_DESCRIPTOR_SUFFIXES):
636
+ descriptors.append(rel_posix)
637
+ elif name.endswith("Mapper.xml"):
638
+ mappers.append(rel_posix)
639
+
612
640
  if config_files is None:
613
- config_files = []
614
- for suffix in _CONFIG_SUFFIXES:
615
- config_files += [
616
- p.relative_to(root).as_posix()
617
- for p in root.rglob(f"application*{suffix}")
618
- ]
641
+ config_files = _walked_config
619
642
  for rel in sorted(set(config_files)):
620
643
  try:
621
644
  text = (root / rel).read_text(encoding="utf-8", errors="replace")
@@ -624,18 +647,10 @@ def scan_security_configuration(
624
647
  out.extend(_scan_commented_config_controls(text, rel))
625
648
  out.extend(_scan_config(blank_hash_comments(text), rel))
626
649
 
627
- # CL-10 — the descriptors. Discovered the same way `environment_resolution`
650
+ # CL-10 — the descriptors. Classified the same way `environment_resolution`
628
651
  # classifies them, so the two surfaces cannot disagree about which files are
629
652
  # deployment descriptors, and a per-environment overlay tree is covered
630
653
  # because the walk finds the file wherever the build put it.
631
- descriptors: "list[str]" = []
632
- try:
633
- for path in root.rglob("*.xml"):
634
- name = path.name.lower()
635
- if name in _DESCRIPTOR_NAMES or name.endswith(_DESCRIPTOR_SUFFIXES):
636
- descriptors.append(path.relative_to(root).as_posix())
637
- except OSError:
638
- pass
639
654
  for rel in sorted(set(descriptors)):
640
655
  try:
641
656
  text = (root / rel).read_text(encoding="utf-8", errors="replace")
@@ -644,13 +659,6 @@ def scan_security_configuration(
644
659
  out.extend(_scan_commented_controls(text, rel, xml=True))
645
660
  out.extend(_scan_descriptor(blank_xml_comments(text), rel))
646
661
 
647
- mappers: "list[str]" = []
648
- try:
649
- for path in root.rglob("*.xml"):
650
- if path.name.endswith("Mapper.xml"):
651
- mappers.append(path.relative_to(root).as_posix())
652
- except OSError:
653
- pass
654
662
  for rel in sorted(set(mappers) - set(descriptors)):
655
663
  try:
656
664
  text = (root / rel).read_text(encoding="utf-8", errors="replace")
@@ -621,6 +621,14 @@ class _GATE00XCustomGateAudit:
621
621
  findings: list[SpringFinding] = []
622
622
  ann_re = _annotation_call_re(token)
623
623
  for rel, source in sources:
624
+ # C3-61: `ann_re` is `@<token>\b…`, so it cannot match a source that does
625
+ # not contain the literal token, and neither can any prefix slice of it.
626
+ # Without this line every source pays a full method `finditer` plus one
627
+ # prefix regex per method before proving it had nothing to find — 98,9 %
628
+ # of (source, token) pairs on the measured repository. Same guard
629
+ # `_advice_body_rules` already carries.
630
+ if token not in source:
631
+ continue
624
632
  class_fqn = _java_fqn(source)
625
633
  annotated_methods: dict[str, tuple[int, str]] = {}
626
634
  for method in _JAVA_METHOD_RE.finditer(source):
@@ -712,6 +720,8 @@ class _GATE00XCustomGateAudit:
712
720
  findings: list[SpringFinding] = []
713
721
  ann_re = _annotation_call_re(token)
714
722
  for rel, source in sources:
723
+ if token not in source: # C3-61, same equivalence as `_proxy_bypass`
724
+ continue
715
725
  class_fqn = _java_fqn(source)
716
726
  for ann in ann_re.finditer(source):
717
727
  args = (ann.group("args") or "").strip()
@@ -0,0 +1,93 @@
1
+ """text_input.py — one authority for decoding text that arrives from outside.
2
+
3
+ Every path list this CLI accepts (stdin, a list file) is bytes written by
4
+ somebody else's shell. Python decodes stdin with the *locale* encoding, which on
5
+ Windows is the ANSI codepage (cp1252), not UTF-8 — so a path piped from
6
+ PowerShell came back mojibake and resolved to nothing:
7
+
8
+ Año…java -> Año…java (UTF-8 bytes read as cp1252)
9
+ src/… -> src/… (the UTF-8 BOM read as three cp1252 chars)
10
+
11
+ Both forms silently produced an empty blast radius on `pr-impact --files -`,
12
+ which is the gating command: `CRITICAL` became a green pipeline (C3-58).
13
+
14
+ The rule here is that the *bytes* decide the encoding, never the platform
15
+ locale: a BOM is authoritative when present, UTF-8 otherwise. A byte sequence
16
+ that is not valid UTF-8 (a genuinely cp1252-encoded list) decodes with
17
+ replacement characters rather than raising — the path then fails to resolve and
18
+ the consumer's diagnostic names encoding as a cause, which beats a traceback.
19
+
20
+ Pure module: no I/O policy, no CLI imports.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import sys
25
+ from pathlib import Path
26
+
27
+ #: Byte-order marks, longest first — UTF-32-LE starts with the UTF-16-LE mark.
28
+ #: The codec named here is the one that *consumes* the mark: `utf-16-le` leaves a
29
+ #: U+FEFF at the head of the text, `utf-16` reads the mark and drops it.
30
+ _BOMS: tuple[tuple[bytes, str], ...] = (
31
+ (b"\x00\x00\xfe\xff", "utf-32"),
32
+ (b"\xff\xfe\x00\x00", "utf-32"),
33
+ (b"\xef\xbb\xbf", "utf-8-sig"),
34
+ (b"\xfe\xff", "utf-16"),
35
+ (b"\xff\xfe", "utf-16"),
36
+ )
37
+
38
+ #: The BOM as a character. `utf-16`/`utf-32` decoding consumes the mark, but a
39
+ #: BOM in the *middle* of a stream (PowerShell writing one per redirection) and
40
+ #: a stream already handed to us as `str` both leave it in the text.
41
+ BOM_CHAR = ""
42
+
43
+
44
+ def sniff_encoding(data: bytes) -> str:
45
+ """Return the encoding named by `data`'s byte-order mark, else `utf-8`."""
46
+ for mark, encoding in _BOMS:
47
+ if data.startswith(mark):
48
+ return encoding
49
+ return "utf-8"
50
+
51
+
52
+ def decode_bytes(data: bytes) -> str:
53
+ """Decode externally supplied bytes. BOM decides; UTF-8 is the default.
54
+
55
+ Never raises: undecodable bytes become U+FFFD so the caller can report an
56
+ unusable path instead of dying on it.
57
+ """
58
+ return data.decode(sniff_encoding(data), errors="replace")
59
+
60
+
61
+ def read_stdin_text() -> str:
62
+ """Read all of stdin as text, decoded by the bytes rather than the locale.
63
+
64
+ Falls back to `sys.stdin.read()` when the stream has no binary buffer —
65
+ Click's `CliRunner` and test doubles replace `sys.stdin` with a `StringIO`.
66
+ """
67
+ buffer = getattr(sys.stdin, "buffer", None)
68
+ if buffer is None:
69
+ return sys.stdin.read()
70
+ return decode_bytes(buffer.read())
71
+
72
+
73
+ def read_text_file(path: Path) -> str:
74
+ """Read a file supplied by the user as text, BOM-aware.
75
+
76
+ `Path.read_text(encoding="utf-8")` raised on the file PowerShell 5.1 writes
77
+ by default: `git diff --name-only > changed.txt` there is UTF-16-LE.
78
+ """
79
+ return decode_bytes(path.read_bytes())
80
+
81
+
82
+ def parse_path_lines(text: str) -> list[str]:
83
+ """Split a path listing into clean entries.
84
+
85
+ Strips the BOM character wherever it survived decoding, normalises CRLF, and
86
+ drops blank lines. Order is preserved; duplicates are the caller's business.
87
+ """
88
+ entries: list[str] = []
89
+ for line in text.splitlines():
90
+ cleaned = line.replace(BOM_CHAR, "").strip()
91
+ if cleaned:
92
+ entries.append(cleaned)
93
+ return entries
sourcecode/verify_edit.py CHANGED
@@ -685,6 +685,37 @@ def verdict(hvw: HeadVsWorking, root: Path) -> VerifyVerdict:
685
685
  exit code: 0=pass, 1=break (a blocking axis proved a regression), 2=unverified
686
686
  (a blocking axis could not be computed — honest, never a silent pass)."""
687
687
  root = _resolve_repo_root(Path(root))
688
+
689
+ # C3-60: an empty change set has one correct answer and it needs none of the
690
+ # six full-repository analyzers below. `working_cir_reused` is only True when
691
+ # `head_vs_working` positively confirmed nothing changed (C3-32's
692
+ # `git_answered and not all_changed`) — never a guess — in which case
693
+ # `cir_working IS cir_head` (the same object) and `changed_files` /
694
+ # `changed_build_files` are both empty by construction. Every axis diffs
695
+ # cir_head against cir_working, so diffing an object against itself can only
696
+ # ever prove "no change"; running the tx/security audits (and everything
697
+ # else) twice over the whole tree to demonstrate that took 8+ minutes on a
698
+ # clean 3 342-file monolith while `cache freshness` already had the answer
699
+ # in under a second. Short-circuit to the answer every axis would compute.
700
+ if hvw.working_cir_reused:
701
+ clean_axes = {
702
+ name: AxisResult(False)
703
+ for name in (
704
+ "contract_broken", "tx_boundary_changed", "security_delta",
705
+ "new_orphan_bean", "custom_rule_violated", "build_config_changed",
706
+ )
707
+ }
708
+ return VerifyVerdict(
709
+ verdict="pass",
710
+ axes=clean_axes,
711
+ changed_files=hvw.changed_files,
712
+ changed_build_files=hvw.changed_build_files,
713
+ blast_radius={"affected_entry_points": 0, "confidence": "high", "note": "no changed files"},
714
+ reasons=(),
715
+ exit_code=0,
716
+ head_sha=hvw.head_sha,
717
+ )
718
+
688
719
  axes: dict[str, AxisResult] = {
689
720
  "contract_broken": _contract_axis(hvw.cir_head, hvw.cir_working),
690
721
  "tx_boundary_changed": _tx_axis(hvw.cir_head, hvw.cir_working, root),
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sourcecode
3
- Version: 4.10.3
3
+ Version: 4.10.5
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
@@ -18,7 +18,7 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
18
  Classifier: Topic :: Utilities
19
19
  Requires-Python: >=3.9
20
20
  Requires-Dist: defusedxml>=0.7
21
- Requires-Dist: mcp>=1.0.0
21
+ Requires-Dist: mcp<2,>=1.2
22
22
  Requires-Dist: pathspec>=1.0
23
23
  Requires-Dist: ruamel-yaml>=0.18
24
24
  Requires-Dist: tomli>=2.0; python_version < '3.11'
@@ -28,12 +28,12 @@ Requires-Dist: tree-sitter-javascript>=0.21; extra == 'ast'
28
28
  Requires-Dist: tree-sitter-typescript>=0.21; extra == 'ast'
29
29
  Requires-Dist: tree-sitter>=0.21; extra == 'ast'
30
30
  Provides-Extra: dev
31
- Requires-Dist: mcp>=1.0.0; extra == 'dev'
31
+ Requires-Dist: mcp<2,>=1.2; extra == 'dev'
32
32
  Requires-Dist: mypy>=1.10; extra == 'dev'
33
33
  Requires-Dist: pytest>=8; extra == 'dev'
34
34
  Requires-Dist: ruff>=0.15; extra == 'dev'
35
35
  Provides-Extra: mcp
36
- Requires-Dist: mcp>=1.0.0; extra == 'mcp'
36
+ Requires-Dist: mcp<2,>=1.2; extra == 'mcp'
37
37
  Description-Content-Type: text/markdown
38
38
 
39
39
  # ASK Engine
@@ -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.10.3-blue)
45
+ ![Version](https://img.shields.io/badge/version-4.10.5-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`**
@@ -106,7 +106,7 @@ brew tap haroundominique/sourcecode && brew install sourcecode
106
106
  # pip / pipx
107
107
  pipx install sourcecode # or: pip install sourcecode
108
108
 
109
- ask version # ask 4.10.3 — and, on a build that has aged,
109
+ ask version # ask 4.10.5 — and, on a build that has aged,
110
110
  # how many releases have probably shipped since
111
111
  ```
112
112
 
@@ -1,7 +1,7 @@
1
- sourcecode/__init__.py,sha256=kRwEKt8ufgmTyXFYrYP-5dM5o3eTQYMoUFRLOvoQ0as,309
1
+ sourcecode/__init__.py,sha256=N-KHrUXD6ch30-2odxw2VCquK5xEfMXbG2ZoDVLuWQU,309
2
2
  sourcecode/adaptive_scanner.py,sha256=yJBKjNpkY6bpueYJ2YnRezen3sYZDecEt7WaaNWdqug,9466
3
3
  sourcecode/archetype.py,sha256=CZvRLpkHot_D8D3JFQVorr-EHDJyh0BbS7RnSTqBigM,40499
4
- sourcecode/architectural_baseline.py,sha256=agDSwGEakdkgLLh5xnd3hiD_TC6pRujWt4klnuYF1wo,18689
4
+ sourcecode/architectural_baseline.py,sha256=ihbYyVgR9MJ2RBuEfayY3rWfubS27MT7m0JPc2NpSWU,20087
5
5
  sourcecode/architectural_delta.py,sha256=E6MkyjWl-1ZgR4KSKnI785gUAfc8nq-lK-jd_rupQBU,12496
6
6
  sourcecode/architecture_analyzer.py,sha256=GFc4ek-s1IHWM7pl-0L32WahZ93AmDgrAMcOuBKA5Dk,61463
7
7
  sourcecode/architecture_summary.py,sha256=BVVRHd952cjRhjHnR6CPrvKgaa-tdM16l-pBi1yDCPs,32395
@@ -9,7 +9,7 @@ sourcecode/ast_extractor.py,sha256=aXJjZ7XjAdxa99hWNm4SyzPqx4gNWiTbUYjNWGNl-Vc,5
9
9
  sourcecode/audit_report.py,sha256=SwFTJAk76Rew9YWpE3s8sHpi6VY-TN2nlLTCdjpUzDk,6031
10
10
  sourcecode/baseline_autocapture.py,sha256=tmMLexQSeaQRRbqxAd7walvGQqnF0mdpxQmkL40rlhI,15623
11
11
  sourcecode/cache.py,sha256=9XJUNSGzqM6DNWy4erUO-OcZY9qkrTCCcOJ-OANVrw8,41409
12
- sourcecode/cache_model.py,sha256=pHUUxg3Eg9kS6vX8kBBB-SCVddIOH-g4alfbBppX25M,17863
12
+ sourcecode/cache_model.py,sha256=fFxq5bmDkqmQ-Bcb5TEyCWDb5VO8UGpd_FV3CsYDlvs,19635
13
13
  sourcecode/call_surface.py,sha256=fiqYfHooxN1fX9oQoysq1LS3LoZcobhyUNjAGEZKpwk,4148
14
14
  sourcecode/caller_metrics.py,sha256=--sFGDnIog_YGu9xZHMcB91F5xZakfaAKvy15xU54hg,7904
15
15
  sourcecode/caller_reach.py,sha256=RRF49tv4-QswraJ2vsZ89VAMOj7BmYXlAdXr-tLP_CA,9483
@@ -18,7 +18,7 @@ sourcecode/chain_rules.py,sha256=Bi6UHfgd-GxWswmnHRcPz5jdbAuqka3Zkz_P-MTvqhw,127
18
18
  sourcecode/change_plan.py,sha256=kFjjp16XYbupgkv1CPkfqo39_SiZPMRQ8OOfC-Vy9eg,7929
19
19
  sourcecode/cir_graphs.py,sha256=9G0HHj1kw2325IDyzo2OpX73BNswEckecf4MZUXB4JM,12078
20
20
  sourcecode/classifier.py,sha256=JBzPwSSrDG-tUHAbcKB678HRbjLpD-ohzbzzO62mgpo,20114
21
- sourcecode/cli.py,sha256=XU1sIXuItcNEHm0F3ZbI7hiDXcAtFXIOEg4swDrtFoM,524880
21
+ sourcecode/cli.py,sha256=yUCVp8xLvSIieX417jnIPJq-Tc_W5ThDle6V3El_Gxk,530346
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=xq3zsqAOAw4AWkoD9khb9xDP_O3KvwqO9k-pf8sbi3g,10951
@@ -79,21 +79,21 @@ sourcecode/perf.py,sha256=2YlJTXjyur1S7hecOMz_joUHAXE5bkcZ85a_LXBEcHQ,13931
79
79
  sourcecode/pipe_contract.py,sha256=PML0Er5d8uDyec4OrdUXuyqHbGPUYndjeaKUjkFz2u4,8369
80
80
  sourcecode/posture.py,sha256=VWnUjqmWp4Koa43dzhonaSvAgMqbOwqaMKCg4ITbgIc,76194
81
81
  sourcecode/pr_comment_renderer.py,sha256=239PmJdf95av_ZW236C7_tvq_ahECeuF9AZxrheJ4OQ,15573
82
- sourcecode/pr_impact.py,sha256=eaFgcPMDsjaF8VZvcitFWCPCabMTi-khtGdxpK0wXTQ,22461
82
+ sourcecode/pr_impact.py,sha256=b315e2ZCRihuw23EXxHIJll1hDs7M2CZTczrZwAxGFQ,24044
83
83
  sourcecode/prepare_context.py,sha256=B8TiBQbazxUOz6D37Dt9zqbOtD5b2S_fR3MB15P9DrI,237206
84
- sourcecode/progress.py,sha256=hbOPMSNMfoHYS-BL1HAH0VAQFR4u3TzWq0oJdeAPdjM,3921
85
- sourcecode/provenance.py,sha256=1_EGgBtJA3f8dOA_XKuENr0mQLnIJw87lXWdlkgFgqY,6661
84
+ sourcecode/progress.py,sha256=In9yXx-WOXLVkj_jxu_ae2_4bABHnhj4nWg4KvetfHM,7921
85
+ sourcecode/provenance.py,sha256=gLbsLv0gze7k7ChM4i5M3MwKfFPzs8wzM72rUd2C5sM,7342
86
86
  sourcecode/ranking_engine.py,sha256=ZAucq_YX2KkWUuAZf4P0lhtQ_38vEFnUhuGtSZd1S0E,12970
87
87
  sourcecode/readiness_timeline.py,sha256=qqc14vL9nj9E4qw4bVZpGhfMLaVaYr9s6Pak_VUIwt0,8008
88
88
  sourcecode/reconciliation.py,sha256=GU-1PTcVr8zcbtC7BASfpHcZndP9AdboXPNQiBc0fzo,34251
89
89
  sourcecode/redactor.py,sha256=SB4hwIvg8h-hvcqKcDWaZvA-aSyn-at-BIRwa0tUv5E,3227
90
90
  sourcecode/reference_facts.py,sha256=Ns495c6eTmq2SrqPkcUrJUJru4_wj_yRWm4YDeJwves,13440
91
- sourcecode/release_info.py,sha256=GUKwjfYAFVJQ233rVuZ0kB5gCBctSQD7NAgCUc_2WMQ,5378
91
+ sourcecode/release_info.py,sha256=6FePTGL0ZSSV_KCYfMUlwT10m7uR0JelH6y1Y-0dPqk,5378
92
92
  sourcecode/relevance_scorer.py,sha256=0AgEt4KrV73nioMqBgjhGjtY7L2C7L7cSyKtj3IKcrw,9408
93
93
  sourcecode/remedies.py,sha256=8lgvKkW1WuBG-QfTnA0uBhEKnwWGSvgSMWCfTjzYhEY,6516
94
94
  sourcecode/rename_refactor.py,sha256=h6dNFlB9aZ_3q6heeHBkgXQeXaT03nvPSsYH6P8qxFg,12965
95
95
  sourcecode/repo_classifier.py,sha256=FG1vaWKdWXsWdl-S8hjVMiTqcwgaRXkDyvK4rPcOGtQ,22681
96
- sourcecode/repository_ir.py,sha256=UdA13zDUvhgZvdUt8BIbfHOXi40OkdvqtpMJG7hYkms,358320
96
+ sourcecode/repository_ir.py,sha256=zCQAZ-SfkbXcgNJJGX5Tv-Z5c2DFTE4GoyzAUitBx48,359936
97
97
  sourcecode/ris.py,sha256=SjjDNMcc9Zr9us9G8ikBVkoPXKK3BpM-dBodSGMh_g4,24783
98
98
  sourcecode/risk.py,sha256=UgTVdMc8XBArO6D9e2Xx9I-kKqx-PW4W0hSe1Jy9qjU,54491
99
99
  sourcecode/rule_catalog.py,sha256=xmphuULUCtg_GWTbriwY25D-N2IaeMXXvB5CNU1xSuA,4925
@@ -103,7 +103,7 @@ sourcecode/sarif.py,sha256=3hQEegUxIZbojFdY59oB-yKsK-rnafHdTNmYsEP2--A,25984
103
103
  sourcecode/scanner.py,sha256=z3CV0rcGunu0Y8mpNgp07wI7nxT0pxw1BkXRRtI0Rpo,9609
104
104
  sourcecode/schema.py,sha256=aHNXDf8LGyUC8ZDE_VS9kiskC2-Oswhi_WnpdGy6HDw,24897
105
105
  sourcecode/security_config.py,sha256=KblMEoRiEjrIE68YsPaUAFebxFp8UM7MS7lAk5CGD8U,3531
106
- sourcecode/security_config_scan.py,sha256=RDxBNm7oVdAaY6gbQgeA2FjHy2pVc6MPRnrBwTnASqY,27247
106
+ sourcecode/security_config_scan.py,sha256=m146K51cA9g9wPRwloLSalHAk0qaEybGj_h0bSO4acQ,27916
107
107
  sourcecode/security_posture.py,sha256=CjJ2Qm87HhKXepnaWx1ncJLW-SxI18udCmUDIkCyAsY,50270
108
108
  sourcecode/semantic_analyzer.py,sha256=bpgdC6m0_ftVtRf3rSdwhbhWjnZnGxRXaZVcfe4BbcQ,95414
109
109
  sourcecode/semantic_impact_engine.py,sha256=t09IirGC3JjQDy33JZd1_WKzQVKXkoNl3-XEUr5kjis,20563
@@ -118,19 +118,20 @@ sourcecode/spring_impact.py,sha256=keWoE1tN5B_SzJx9F-53RKEL0b2edhJdZMTfocmJZLY,8
118
118
  sourcecode/spring_model.py,sha256=zOAgFmrRbG4a6KLm1TJl55aWMyPNsz3OS3FSczqPG6A,16594
119
119
  sourcecode/spring_profiles.py,sha256=-kwrCK0O-MRjrCp6SA1d31t--o4Tg87iqPVrTwxoIUs,19605
120
120
  sourcecode/spring_properties.py,sha256=kPTk5qAJxdbHc1hclhHAP0O-hCIzBn8PTh3lzl-I4a4,8400
121
- sourcecode/spring_security_audit.py,sha256=KkhIx1WhAD9ccw3P-CcHP3d8p0OAOivKT5CrX2Zuq8k,46257
121
+ sourcecode/spring_security_audit.py,sha256=8IFHdouvurxyK2n60PPxflhJjJ9u-hl8EC7iFns2ST0,46899
122
122
  sourcecode/spring_semantic.py,sha256=UrmLg_4gmBVmMqvSnUMgSReVEERVGv3X7LYDBEs6k_o,16598
123
123
  sourcecode/spring_tx_analyzer.py,sha256=_wqjRktqdPS6PiXXxtTkCD1t6BaE2uhqx6ioYY-ULAw,44153
124
124
  sourcecode/summarizer.py,sha256=0aD4x3vgPngqBCEBKGuES1J2Vk5f7mqCm_ZWErwm3js,27025
125
125
  sourcecode/target_admission.py,sha256=wFZ4pzlxhiF6Q6s2lEAZEzcCfj1Y6xNvujjt8MdO0Qo,7154
126
126
  sourcecode/test_gap_ranking.py,sha256=hl-tTyQUXZGJycG1b6npbLeE_DaVHaOsvPwG9qqC5y8,15711
127
127
  sourcecode/test_sources.py,sha256=6qXI47rVt_DFEbziFUyU-_KIUIGt8YQ5Uf4-sdiAhKU,9277
128
+ sourcecode/text_input.py,sha256=3pL8DxR_DMI3ece-B70DnppvrEaLh4R17Pp8dK9G_hc,3596
128
129
  sourcecode/token_estimate.py,sha256=G-ivzIJfVq5TLQN04dC0WK4LZJjWVYL54Ebcxe4fJ7M,9268
129
130
  sourcecode/tree_utils.py,sha256=8GAkIfQAsvtEudIeW1l4ooH_oRtrWR8cpJQJsEa_Pfw,2093
130
131
  sourcecode/type_usage_surface.py,sha256=51IrKRQoIoRnlsiDjHnqpJBn2rc6E59aRhgS0HTzAF0,4428
131
132
  sourcecode/validation_inference.py,sha256=-oWJqE6PqkqcZbCFDJeWCQHgI9Dbuv5ggJWD3LE_2IU,20817
132
133
  sourcecode/validation_surface.py,sha256=jYL-hkDjaaRKkAt7ZUcbxm3Dydl8o6KVRhTkuPXKPVc,31460
133
- sourcecode/verify_edit.py,sha256=Toqx4z47gLUmcj5F673eTFq1dE_6e4T3mXVesjkPx6g,35955
134
+ sourcecode/verify_edit.py,sha256=IN-cmLxz-gIosbw6jfxKLDE7d5CdXocNAFkEnln806c,37499
134
135
  sourcecode/verify_repo.py,sha256=ltSP1nFc726k2aTHCu5dfhxQgxQwz3nrUNDMDN-aDa8,12158
135
136
  sourcecode/verify_rules.py,sha256=YxG5JkDXBVpwbU1_tIKVtU8_eaF3zjpsq05tjpN4Z7Y,19401
136
137
  sourcecode/version_check.py,sha256=CHp6ZxTIfo8kyHPCBgJA1uFC0xQCoXMuuOfrW8QTL8o,4942
@@ -193,8 +194,8 @@ sourcecode/telemetry/consent.py,sha256=pQdl-QeLl6Gcibn0eWHSKZrm-HYSsjpVqOnjrgFp8
193
194
  sourcecode/telemetry/events.py,sha256=4_yeO58U-Cwc1Qb27VB0_EjhmroY0k91n3_VGxeALB8,2776
194
195
  sourcecode/telemetry/filters.py,sha256=RzxauTz8HliO4BllQnXEXc7zTeqdCZi5MgqGEDuW7OQ,6570
195
196
  sourcecode/telemetry/transport.py,sha256=4gGHsq0WeY9VywEZXA3vUxykfiYnw9uuqfjAAec7F8o,1681
196
- sourcecode-4.10.3.dist-info/METADATA,sha256=6_uj0P5A9uKdHBcaXMZQic-g9lTXCknIu4m9X9sYj1g,36657
197
- sourcecode-4.10.3.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
198
- sourcecode-4.10.3.dist-info/entry_points.txt,sha256=-JEAdChrK5We51kZcb7OaDcyil-dHBjBPL-NhuO-QY8,89
199
- sourcecode-4.10.3.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
200
- sourcecode-4.10.3.dist-info/RECORD,,
197
+ sourcecode-4.10.5.dist-info/METADATA,sha256=YVaP0d14yBclh0t3bccUj_lbJOuaI7cFJbWzotlts7g,36660
198
+ sourcecode-4.10.5.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
199
+ sourcecode-4.10.5.dist-info/entry_points.txt,sha256=-JEAdChrK5We51kZcb7OaDcyil-dHBjBPL-NhuO-QY8,89
200
+ sourcecode-4.10.5.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
201
+ sourcecode-4.10.5.dist-info/RECORD,,