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 +1 -1
- sourcecode/cli.py +50 -9
- sourcecode/pr_impact.py +38 -4
- sourcecode/progress.py +98 -5
- sourcecode/repository_ir.py +38 -1
- sourcecode/security_config_scan.py +30 -22
- sourcecode/text_input.py +93 -0
- {sourcecode-4.10.4.dist-info → sourcecode-4.10.5.dist-info}/METADATA +3 -3
- {sourcecode-4.10.4.dist-info → sourcecode-4.10.5.dist-info}/RECORD +12 -11
- {sourcecode-4.10.4.dist-info → sourcecode-4.10.5.dist-info}/WHEEL +0 -0
- {sourcecode-4.10.4.dist-info → sourcecode-4.10.5.dist-info}/entry_points.txt +0 -0
- {sourcecode-4.10.4.dist-info → sourcecode-4.10.5.dist-info}/licenses/LICENSE +0 -0
sourcecode/__init__.py
CHANGED
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
9473
|
-
|
|
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
|
|
99
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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/repository_ir.py
CHANGED
|
@@ -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.
|
|
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")
|
sourcecode/text_input.py
ADDED
|
@@ -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.
|
|
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
|
-

|
|
46
46
|

|
|
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.
|
|
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=
|
|
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=
|
|
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=
|
|
82
|
+
sourcecode/pr_impact.py,sha256=b315e2ZCRihuw23EXxHIJll1hDs7M2CZTczrZwAxGFQ,24044
|
|
83
83
|
sourcecode/prepare_context.py,sha256=B8TiBQbazxUOz6D37Dt9zqbOtD5b2S_fR3MB15P9DrI,237206
|
|
84
|
-
sourcecode/progress.py,sha256=
|
|
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=
|
|
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=
|
|
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.
|
|
197
|
-
sourcecode-4.10.
|
|
198
|
-
sourcecode-4.10.
|
|
199
|
-
sourcecode-4.10.
|
|
200
|
-
sourcecode-4.10.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|