sourcecode 4.10.4__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.4"
7
+ __version__ = "4.10.5"
sourcecode/cli.py CHANGED
@@ -18,6 +18,7 @@ from sourcecode import __version__
18
18
  from sourcecode.error_schema import EXECUTION_FAILED_CODE, INVALID_INPUT_CODE, build_error_envelope
19
19
  from sourcecode.entrypoint_classifier import is_production_entry_point, normalize_entry_point
20
20
  from sourcecode.progress import Progress
21
+ from sourcecode.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(
@@ -9461,18 +9496,21 @@ def pr_impact_cmd(
9461
9496
  # --files accepts three shapes: a list file, '-' for stdin, or an inline
9462
9497
  # comma-separated list. The list-file shape was the only documented one and
9463
9498
  # the only one the error hint mentioned.
9464
- _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()
9465
9505
  if _files_raw == "-":
9466
- 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())
9467
9507
  _files_desc = "stdin"
9468
9508
  else:
9469
9509
  _files_path = Path(_files_raw)
9470
9510
  if _files_path.is_file():
9471
- changed_files = [
9472
- line.strip()
9473
- for line in _files_path.read_text(encoding="utf-8").splitlines()
9474
- if line.strip()
9475
- ]
9511
+ changed_files = _text_input.parse_path_lines(
9512
+ _text_input.read_text_file(_files_path)
9513
+ )
9476
9514
  _files_desc = str(_files_path)
9477
9515
  elif all(
9478
9516
  p.strip().lower().endswith(".java")
@@ -10309,6 +10347,9 @@ def modernize_cmd(
10309
10347
  sys.stdout.buffer.flush()
10310
10348
  except AttributeError:
10311
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()
10312
10353
 
10313
10354
  if copy:
10314
10355
  _copy_to_clipboard(output)
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
@@ -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")
@@ -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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sourcecode
3
- Version: 4.10.4
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
@@ -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.4-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.4 — 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,4 +1,4 @@
1
- sourcecode/__init__.py,sha256=CWukY8bLFVrEUIM2D9buLe9rM2OWPuG0EFvr8TfFs2s,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
4
  sourcecode/architectural_baseline.py,sha256=ihbYyVgR9MJ2RBuEfayY3rWfubS27MT7m0JPc2NpSWU,20087
@@ -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=5_80ItmEER0L6udbjLBZ3NbDH-oG7-bE8rusLe1dLEo,528213
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,9 +79,9 @@ 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
84
+ sourcecode/progress.py,sha256=In9yXx-WOXLVkj_jxu_ae2_4bABHnhj4nWg4KvetfHM,7921
85
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
@@ -93,7 +93,7 @@ sourcecode/relevance_scorer.py,sha256=0AgEt4KrV73nioMqBgjhGjtY7L2C7L7cSyKtj3IKcr
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
@@ -125,6 +125,7 @@ sourcecode/summarizer.py,sha256=0aD4x3vgPngqBCEBKGuES1J2Vk5f7mqCm_ZWErwm3js,2702
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
@@ -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.4.dist-info/METADATA,sha256=kDEJYN6SFhGKasJ12BhdsBF7yyoDst5XmkNscJCfFb4,36660
197
- sourcecode-4.10.4.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
198
- sourcecode-4.10.4.dist-info/entry_points.txt,sha256=-JEAdChrK5We51kZcb7OaDcyil-dHBjBPL-NhuO-QY8,89
199
- sourcecode-4.10.4.dist-info/licenses/LICENSE,sha256=7DdHrU9Z_3e7dSvq4ISijZNjnuHo5NIHNiHDouMQ9JU,10491
200
- sourcecode-4.10.4.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,,